diff --git a/app/src/components/marketplace/AddMarketplaceModal.test.tsx b/app/src/components/marketplace/AddMarketplaceModal.test.tsx new file mode 100644 index 0000000..58002a9 --- /dev/null +++ b/app/src/components/marketplace/AddMarketplaceModal.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useAppState } from "../../store/appState"; +import type { AppSettings } from "../../lib/types"; + +const addMarketplace = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + addMarketplace: (...a: unknown[]) => addMarketplace(...a), +})); + +import AddMarketplaceModal from "./AddMarketplaceModal"; + +describe("AddMarketplaceModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppState.setState({ + appSettings: { + marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }], + marketplaces: [], + global_marketplace_installs: [], + } as unknown as AppSettings, + }); + }); + + it("submits name, url, branch and account", async () => { + const onAdded = vi.fn(); + addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] }); + render(); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } }); + fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } }); + fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } }); + fireEvent.click(screen.getByRole("button", { name: "Add marketplace" })); + await waitFor(() => expect(onAdded).toHaveBeenCalled()); + expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1"); + }); + + it("rejects non-https URLs before calling the backend", () => { + render(); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } }); + fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } }); + expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled(); + expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument(); + }); + + it("shows the backend error and stays open", async () => { + addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)"); + render(); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } }); + fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } }); + fireEvent.click(screen.getByRole("button", { name: "Add marketplace" })); + expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/AddMarketplaceModal.tsx b/app/src/components/marketplace/AddMarketplaceModal.tsx new file mode 100644 index 0000000..a795307 --- /dev/null +++ b/app/src/components/marketplace/AddMarketplaceModal.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import Field, { inputClass, selectClass } from "../ui/Field"; +import { addMarketplace } from "../../lib/tauri-commands"; +import { useAppState } from "../../store/appState"; +import type { MarketplaceSnapshot } from "../../lib/types"; + +interface Props { + onClose: () => void; + onAdded: (snapshot: MarketplaceSnapshot) => void; +} + +export default function AddMarketplaceModal({ onClose, onAdded }: Props) { + const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []); + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [branch, setBranch] = useState(""); + const [accountId, setAccountId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const trimmedUrl = url.trim(); + const urlProblem = + trimmedUrl !== "" && !trimmedUrl.startsWith("https://") + ? "The repository URL must start with https:// (SSH URLs are not supported)." + : null; + const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy; + + const submit = async () => { + setBusy(true); + setError(null); + try { + const snap = await addMarketplace( + name.trim(), + trimmedUrl, + branch.trim() === "" ? null : branch.trim(), + accountId === "" ? null : accountId, + ); + onAdded(snap); + onClose(); + } catch (e) { + setError(typeof e === "string" ? e : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + + + + + } + > +
+ + {(id) => ( + setName(e.target.value)} className={inputClass} placeholder="Team marketplace" /> + )} + + + {(id) => ( + setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" /> + )} + + + {(id) => ( + setBranch(e.target.value)} className={inputClass} placeholder="main" /> + )} + + + {(id) => ( + + )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/app/src/components/marketplace/BrowsePane.test.tsx b/app/src/components/marketplace/BrowsePane.test.tsx new file mode 100644 index 0000000..4c74e50 --- /dev/null +++ b/app/src/components/marketplace/BrowsePane.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { useAppState } from "../../store/appState"; +import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +vi.mock("./InstallControls", () => ({ default: () =>
install controls
})); +vi.mock("./AddMarketplaceModal", () => ({ default: () =>
add modal
})); + +import BrowsePane from "./BrowsePane"; + +const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial = {}): CatalogItem => ({ + kind, + key, + name: key, + description: `${key} description`, + path: key, + invalid: null, + hook_commands: [], + preview: `${key} preview body`, + ...patch, +}); + +const snapshot: MarketplaceSnapshot = { + marketplace_id: "m1", + head_commit: "a".repeat(40), + fetched_at: "2026-09-27T12:00:00Z", + fetch_error: "network unreachable", + items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })], +}; + +function api(patch: Partial = {}): MarketplaceApi { + return { + snapshots: [snapshot], + updates: [], + loading: false, + refreshing: [], + load: vi.fn(), + refresh: vi.fn(), + reloadState: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + setDisabled: vi.fn(), + update: vi.fn(), + forget: vi.fn(), + remove: vi.fn(async () => true), + ...patch, + }; +} + +describe("BrowsePane", () => { + beforeEach(() => { + useAppState.setState({ + appSettings: { + marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }], + marketplace_accounts: [], + global_marketplace_installs: [], + } as unknown as AppSettings, + projects: [], + marketplaceFilterProjectId: null, + }); + }); + + it("lists items, filters by kind and search, and shows detail", () => { + render(); + expect(screen.getByText("network unreachable")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("radio", { name: "Hooks" })); + expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("radio", { name: "All" })); + fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } }); + expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ })); + expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument(); + expect(screen.getByText("install controls")).toBeInTheDocument(); + }); + + it("shows why an item is invalid", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /broken/ })); + expect(screen.getByText("SKILL.md missing")).toBeInTheDocument(); + }); + + it("refreshes one marketplace", () => { + const mp = api(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" })); + expect(mp.refresh).toHaveBeenCalledWith("m1"); + }); + + it("offers Add when there are no marketplaces", () => { + useAppState.setState({ + appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add marketplace" })); + expect(screen.getByText("add modal")).toBeInTheDocument(); + }); + + it("confirms before removing a marketplace (F6)", () => { + const mp = api(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove Starter" })); + expect(screen.getByText(/Source removed/)).toBeInTheDocument(); + expect(screen.getByText(/Forget/)).toBeInTheDocument(); + expect(mp.remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" })); + expect(mp.remove).toHaveBeenCalledWith("m1"); + }); +}); diff --git a/app/src/components/marketplace/BrowsePane.tsx b/app/src/components/marketplace/BrowsePane.tsx index b97c93e..08bf9ec 100644 --- a/app/src/components/marketplace/BrowsePane.tsx +++ b/app/src/components/marketplace/BrowsePane.tsx @@ -1,9 +1,248 @@ +import { useMemo, useState } from "react"; import type { MarketplaceApi } from "../../hooks/useMarketplace"; +import { useAppState } from "../../store/appState"; +import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace"; +import { updateMarketplace } from "../../lib/tauri-commands"; +import type { CatalogItem, ItemKind, Marketplace } from "../../lib/types"; +import Button from "../ui/Button"; +import Modal from "../ui/Modal"; +import SegmentedControl from "../ui/SegmentedControl"; +import { inputClass, selectClass } from "../ui/Field"; +import AddMarketplaceModal from "./AddMarketplaceModal"; +import ItemDetail from "./ItemDetail"; + +type KindFilter = ItemKind | "all"; + +const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never"); export default function BrowsePane({ mp }: { mp: MarketplaceApi }) { + const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []); + const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []); + const globalInstalls = useAppState((s) => s.appSettings?.global_marketplace_installs ?? []); + const projects = useAppState((s) => s.projects); + const filterId = useAppState((s) => s.marketplaceFilterProjectId); + const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId); + const [kind, setKind] = useState("all"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(null); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + return mp.snapshots.flatMap((snap) => + snap.items + .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 })), + ); + }, [mp.snapshots, kind, query]); + + const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id; + + const changeAccount = async (m: Marketplace, accountId: string | null) => { + await updateMarketplace({ ...m, account_id: accountId }); + await mp.reloadState(); + }; + + /** Global + every project's installs of this marketplace, for the removal warning. */ + const installCountFor = (marketplaceId: string) => { + const global = globalInstalls.filter((i) => i.marketplace_id === marketplaceId).length; + const perProject = projects.reduce( + (sum, p) => sum + p.marketplace_installs.filter((i) => i.marketplace_id === marketplaceId).length, + 0, + ); + return global + perProject; + }; + return ( -

- {mp.snapshots.length} marketplace{mp.snapshots.length === 1 ? "" : "s"} configured. -

+
+ + +
+ + label="Item kind" + value={kind} + onChange={setKind} + segments={[ + { value: "all", label: "All" }, + ...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })), + ]} + /> + setQuery(e.target.value)} + placeholder="Search" + className={inputClass} + /> +
    + {rows.map(({ marketplaceId, item }) => { + const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key }); + const isSel = + selected?.marketplaceId === marketplaceId && + selected.item.kind === item.kind && + selected.item.key === item.key; + return ( +
  • + +
  • + ); + })} + {rows.length === 0 && mp.snapshots.length > 0 && ( +
  • No items match.
  • + )} +
+
+ +
+ {selected ? ( + + ) : ( +

Select an item to see what it contains and install it.

+ )} +
+ + {adding && ( + setAdding(false)} + onAdded={() => { + void mp.reloadState(); + void mp.load(); + }} + /> + )} + + {removing && ( + setRemoving(null)} + footer={ + <> + + + + } + > +

+ {installCountFor(removing.id)} install{installCountFor(removing.id) === 1 ? "" : "s"} stay listed as + “Source removed” and are removed from containers at their next sync. Use “Forget” on the Installed tab + instead if you want to drop them immediately. +

+
+ )} +
); } diff --git a/app/src/components/marketplace/HookConfirmModal.tsx b/app/src/components/marketplace/HookConfirmModal.tsx new file mode 100644 index 0000000..f5f4c6e --- /dev/null +++ b/app/src/components/marketplace/HookConfirmModal.tsx @@ -0,0 +1,45 @@ +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import type { CatalogItem } from "../../lib/types"; + +interface Props { + item: CatalogItem; + 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) { + return ( + + + + + } + > + {item.hook_commands.length === 0 ? ( +

This hook declares no commands.

+ ) : ( +
    + {item.hook_commands.map((c) => ( +
  • + + {c} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/marketplace/InstallControls.test.tsx b/app/src/components/marketplace/InstallControls.test.tsx new file mode 100644 index 0000000..73aaee0 --- /dev/null +++ b/app/src/components/marketplace/InstallControls.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import InstallControls from "./InstallControls"; +import { useAppState } from "../../store/appState"; +import type { AppSettings, CatalogItem, Project } from "../../lib/types"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +const C = "c".repeat(40); + +function api(): MarketplaceApi { + return { + snapshots: [], + updates: [], + loading: false, + refreshing: [], + load: vi.fn(), + refresh: vi.fn(), + reloadState: vi.fn(), + install: vi.fn(async () => true), + uninstall: vi.fn(async () => true), + setDisabled: vi.fn(async () => true), + update: vi.fn(), + forget: vi.fn(), + remove: vi.fn(), + }; +} + +const item = (kind: CatalogItem["kind"], patch: Partial = {}): CatalogItem => ({ + kind, + key: "rev", + name: "rev", + description: "", + path: `agents/rev.md`, + invalid: null, + hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [], + preview: "", + ...patch, +}); + +const project = (id: string, patch: Partial = {}) => + ({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project; + +function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) { + useAppState.setState({ + appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings, + projects, + marketplaceFilterProjectId: null, + }); +} + +const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" }; + +describe("InstallControls", () => { + beforeEach(() => seed([], [project("p1"), project("p2")])); + + it("installs for all projects", () => { + const mp = api(); + render(); + fireEvent.click(screen.getByRole("switch", { name: "All projects" })); + expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }); + }); + + it("installs for one project", () => { + const mp = api(); + render(); + fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ })); + expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }); + }); + + 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(); + const row1 = screen.getByTestId("install-row-p1"); + expect(within(row1).getByText("Inherited")).toBeInTheDocument(); + fireEvent.click(within(row1).getByRole("checkbox")); + expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true); + const row2 = screen.getByTestId("install-row-p2"); + expect(within(row2).getByText("Opted out")).toBeInTheDocument(); + fireEvent.click(within(row2).getByRole("checkbox")); + expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false); + }); + + it("removes a project-only install", () => { + const mp = api(); + seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]); + render(); + 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(); + 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" }); + }); + + it("disables everything for an invalid item", () => { + render(); + 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(); + expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument(); + expect(screen.getByTestId("install-row-p2")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/InstallControls.tsx b/app/src/components/marketplace/InstallControls.tsx new file mode 100644 index 0000000..c154dbd --- /dev/null +++ b/app/src/components/marketplace/InstallControls.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { useAppState } from "../../store/appState"; +import { projectItemState, type ProjectItemState } from "../../lib/marketplace"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; +import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types"; +import Toggle from "../ui/Toggle"; +import HookConfirmModal from "./HookConfirmModal"; + +const STATE_LABEL: Record = { + none: "", + inherited: "Inherited", + opted_out: "Opted out", + project: "This project", + project_pinned_differently: "Pinned to a different commit", +}; + +interface Props { + mp: MarketplaceApi; + item: CatalogItem; + marketplaceId: string; +} + +export default function InstallControls({ mp, item, marketplaceId }: Props) { + const appSettings = useAppState((s) => s.appSettings); + const projects = useAppState((s) => s.projects); + const filterId = useAppState((s) => s.marketplaceFilterProjectId); + const [pendingHook, setPendingHook] = useState(null); + const [busy, setBusy] = useState(false); + + const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key }; + const globalInstalls = appSettings?.global_marketplace_installs ?? []; + const isGlobal = globalInstalls.some( + (g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key, + ); + const disabled = item.invalid !== null || busy; + const shown = filterId ? projects.filter((p) => p.id === filterId) : projects; + + const run = async (fn: () => Promise) => { + setBusy(true); + try { + await fn(); + } finally { + setBusy(false); + } + }; + + /** Every install goes through here so a hook is always confirmed first. */ + const install = (scope: InstallScope) => { + if (item.kind === "hook") { + setPendingHook(scope); + return; + } + void run(() => mp.install(ref, scope)); + }; + + const toggleProject = (projectId: string, state: ProjectItemState) => { + const scope: InstallScope = { type: "project", project_id: projectId }; + switch (state) { + case "none": + install(scope); + break; + case "inherited": + void run(() => mp.setDisabled(projectId, ref, true)); + break; + case "opted_out": + void run(() => mp.setDisabled(projectId, ref, false)); + break; + case "project": + case "project_pinned_differently": + void run(() => mp.uninstall(ref, scope)); + break; + } + }; + + return ( +
+ (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))} + /> +
    + {shown.map((p) => { + const state = projectItemState(ref, globalInstalls, p); + const checked = state === "inherited" || state === "project" || state === "project_pinned_differently"; + return ( +
  • + + {STATE_LABEL[state] && ( + {STATE_LABEL[state]} + )} +
  • + ); + })} +
+ {projects.length === 0 && ( +

No projects yet — “All projects” also covers projects added later.

+ )} + {pendingHook && ( + setPendingHook(null)} + onConfirm={() => { + const scope = pendingHook; + setPendingHook(null); + void run(() => mp.install(ref, scope)); + }} + /> + )} +
+ ); +} diff --git a/app/src/components/marketplace/ItemDetail.tsx b/app/src/components/marketplace/ItemDetail.tsx new file mode 100644 index 0000000..3a4647e --- /dev/null +++ b/app/src/components/marketplace/ItemDetail.tsx @@ -0,0 +1,56 @@ +import type { CatalogItem } from "../../lib/types"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; +import { KIND_LABELS } from "../../lib/marketplace"; +import StatusIndicator from "../ui/StatusIndicator"; +import InstallControls from "./InstallControls"; + +interface Props { + mp: MarketplaceApi; + item: CatalogItem; + marketplaceId: string; +} + +export default function ItemDetail({ mp, item, marketplaceId }: Props) { + return ( +
+
+

+ {KIND_LABELS[item.kind].replace(/s$/, "")} · {item.path} +

+

{item.name}

+ {item.description &&

{item.description}

} +
+ {item.invalid && ( +
+ +

{item.invalid}

+
+ )} + {item.kind === "hook" && item.hook_commands.length > 0 && ( +
+

Commands this hook runs

+
    + {item.hook_commands.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ )} + {item.preview && ( +
+          {item.preview}
+        
+ )} +
+

Install

+ +

+ Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes + apply to new Claude sessions. +

+
+
+ ); +}