diff --git a/apps/web/src/features/global-extensions/api/globalExtensionsApi.ts b/apps/web/src/features/global-extensions/api/globalExtensionsApi.ts new file mode 100644 index 0000000..2500e3c --- /dev/null +++ b/apps/web/src/features/global-extensions/api/globalExtensionsApi.ts @@ -0,0 +1,102 @@ +import type { + GlobalExtensionMeta, + InstallGlobalExtensionRequest, + ListGlobalExtensionsResponse, + ListMarketplacesResponse, + MarketplaceListing, + SearchMarketplaceResponse, +} from "@tangent/shared/contracts"; + +import { apiFetch } from "@/shared/lib/apiFetch"; + +/** Throws with the server's `{ error }` message when the response failed. */ +async function ensureOk(res: Response): Promise { + if (res.ok) return; + const body = (await res.json().catch(() => undefined)) as + | { error?: string } + | undefined; + throw new Error(body?.error ?? `Request failed with status ${res.status}`); +} + +export async function listGlobalExtensions(): Promise { + const res = await apiFetch("/api/extensions"); + await ensureOk(res); + return ((await res.json()) as ListGlobalExtensionsResponse).extensions; +} + +export async function listMarketplaces(): Promise< + ListMarketplacesResponse["marketplaces"] +> { + const res = await apiFetch("/api/extensions/marketplaces"); + await ensureOk(res); + return ((await res.json()) as ListMarketplacesResponse).marketplaces; +} + +export async function searchMarketplace( + id: string, + q: string, +): Promise { + const res = await apiFetch( + `/api/extensions/marketplaces/${encodeURIComponent(id)}/search?q=${encodeURIComponent(q)}`, + ); + await ensureOk(res); + return ((await res.json()) as SearchMarketplaceResponse).listings; +} + +export async function installExtensionFromSource( + body: InstallGlobalExtensionRequest, +): Promise { + const res = await apiFetch("/api/extensions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + await ensureOk(res); + return ((await res.json()) as { extension: GlobalExtensionMeta }).extension; +} + +export interface InstallFromFileInput { + file: File; + id?: string; + tools?: string[]; + replace?: boolean; +} + +export async function installExtensionFromFile( + input: InstallFromFileInput, +): Promise { + const form = new FormData(); + form.append("file", input.file); + if (input.id) form.append("id", input.id); + if (input.tools) form.append("tools", JSON.stringify(input.tools)); + if (input.replace) form.append("replace", "true"); + + const res = await apiFetch("/api/extensions", { method: "POST", body: form }); + await ensureOk(res); + return ((await res.json()) as { extension: GlobalExtensionMeta }).extension; +} + +export interface UpdateExtensionInput { + tools?: string[]; + name?: string; +} + +export async function updateGlobalExtension( + id: string, + body: UpdateExtensionInput, +): Promise { + const res = await apiFetch(`/api/extensions/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + await ensureOk(res); + return ((await res.json()) as { extension: GlobalExtensionMeta }).extension; +} + +export async function uninstallGlobalExtension(id: string): Promise { + const res = await apiFetch(`/api/extensions/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + await ensureOk(res); +} diff --git a/apps/web/src/features/global-extensions/hooks/useDebouncedValue.ts b/apps/web/src/features/global-extensions/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..66bd36a --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useDebouncedValue.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +/** Returns `value` delayed by `delayMs`, resetting the timer on every change. */ +export function useDebouncedValue(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/apps/web/src/features/global-extensions/hooks/useExtensionMarketplaces.ts b/apps/web/src/features/global-extensions/hooks/useExtensionMarketplaces.ts new file mode 100644 index 0000000..d00ced2 --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useExtensionMarketplaces.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; + +import { listMarketplaces } from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export function useExtensionMarketplaces() { + return useQuery({ + queryKey: GlobalExtensionQueryKeys.Marketplaces(), + queryFn: listMarketplaces, + }); +} diff --git a/apps/web/src/features/global-extensions/hooks/useGlobalExtensions.ts b/apps/web/src/features/global-extensions/hooks/useGlobalExtensions.ts new file mode 100644 index 0000000..f150217 --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useGlobalExtensions.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; + +import { listGlobalExtensions } from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export function useGlobalExtensions() { + return useQuery({ + queryKey: GlobalExtensionQueryKeys.List(), + queryFn: listGlobalExtensions, + }); +} diff --git a/apps/web/src/features/global-extensions/hooks/useInstallGlobalExtension.ts b/apps/web/src/features/global-extensions/hooks/useInstallGlobalExtension.ts new file mode 100644 index 0000000..865255d --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useInstallGlobalExtension.ts @@ -0,0 +1,49 @@ +import type { GlobalExtensionMeta } from "@tangent/shared/contracts"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { + installExtensionFromFile, + installExtensionFromSource, + type InstallFromFileInput, +} from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export type InstallExtensionInput = + | { + kind: "source"; + source: string; + id?: string; + tools?: string[]; + replace?: boolean; + } + | ({ kind: "file" } & InstallFromFileInput); + +function install(input: InstallExtensionInput): Promise { + if (input.kind === "file") { + return installExtensionFromFile({ + file: input.file, + id: input.id, + tools: input.tools, + replace: input.replace, + }); + } + return installExtensionFromSource({ + source: input.source, + id: input.id, + tools: input.tools, + replace: input.replace, + }); +} + +export function useInstallGlobalExtension() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: install, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: GlobalExtensionQueryKeys.All(), + }); + }, + }); +} diff --git a/apps/web/src/features/global-extensions/hooks/useMarketplaceSearch.ts b/apps/web/src/features/global-extensions/hooks/useMarketplaceSearch.ts new file mode 100644 index 0000000..475f916 --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useMarketplaceSearch.ts @@ -0,0 +1,13 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { searchMarketplace } from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export function useMarketplaceSearch(id: string, q: string, enabled = true) { + return useQuery({ + queryKey: GlobalExtensionQueryKeys.Search(id, q), + queryFn: () => searchMarketplace(id, q), + enabled, + placeholderData: keepPreviousData, + }); +} diff --git a/apps/web/src/features/global-extensions/hooks/useUninstallGlobalExtension.ts b/apps/web/src/features/global-extensions/hooks/useUninstallGlobalExtension.ts new file mode 100644 index 0000000..63360d9 --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useUninstallGlobalExtension.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { uninstallGlobalExtension } from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export function useUninstallGlobalExtension() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: uninstallGlobalExtension, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: GlobalExtensionQueryKeys.All(), + }); + }, + }); +} diff --git a/apps/web/src/features/global-extensions/hooks/useUpdateGlobalExtension.ts b/apps/web/src/features/global-extensions/hooks/useUpdateGlobalExtension.ts new file mode 100644 index 0000000..d57baaf --- /dev/null +++ b/apps/web/src/features/global-extensions/hooks/useUpdateGlobalExtension.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { + type UpdateExtensionInput, + updateGlobalExtension, +} from "@/features/global-extensions/api/globalExtensionsApi"; +import { GlobalExtensionQueryKeys } from "@/features/global-extensions/model/globalExtensionQueryKeys"; + +export function useUpdateGlobalExtension() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: UpdateExtensionInput }) => + updateGlobalExtension(id, body), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: GlobalExtensionQueryKeys.All(), + }); + }, + }); +} diff --git a/apps/web/src/features/global-extensions/model/globalExtensionQueryKeys.ts b/apps/web/src/features/global-extensions/model/globalExtensionQueryKeys.ts new file mode 100644 index 0000000..c991e84 --- /dev/null +++ b/apps/web/src/features/global-extensions/model/globalExtensionQueryKeys.ts @@ -0,0 +1,8 @@ +export const GlobalExtensionQueryKeys = { + All: () => ["global-extensions"] as const, + List: () => [...GlobalExtensionQueryKeys.All(), "list"] as const, + Marketplaces: () => + [...GlobalExtensionQueryKeys.All(), "marketplaces"] as const, + Search: (id: string, q: string) => + [...GlobalExtensionQueryKeys.All(), "search", id, q] as const, +} as const; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 14e8e7a..f5c6af1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -294,6 +294,20 @@ .subtle-scrollbar::-webkit-scrollbar-track { background: transparent; } + /* Always-visible thin scrollbar (classic, non-overlay). Omits the standard + `scrollbar-width` so Chromium keeps the webkit track rendered at all times. */ + .visible-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .visible-scrollbar::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 9999px; + } + .visible-scrollbar::-webkit-scrollbar-track { + background: color-mix(in oklab, var(--border) 25%, transparent); + border-radius: 9999px; + } } /* Mac-genie-like collapse: the bubble is sucked straight up toward the row diff --git a/apps/web/src/routes/routeTree.tsx b/apps/web/src/routes/routeTree.tsx index 249d5ed..1dd44a2 100644 --- a/apps/web/src/routes/routeTree.tsx +++ b/apps/web/src/routes/routeTree.tsx @@ -15,6 +15,7 @@ import { NotFoundPage } from "./not-found/NotFoundPage"; import { SessionChatPage } from "./sessions/SessionChatPage"; import { SessionsPage } from "./sessions/SessionsPage"; import { BundleEditorsSettings } from "./settings/sections/bundle-editors/BundleEditorsSettings"; +import { GlobalExtensionsSettings } from "./settings/sections/global-extensions/GlobalExtensionsSettings"; import { GlobalMemorySettings } from "./settings/sections/GlobalMemorySettings"; import { SettingsLayout } from "./settings/SettingsLayout"; @@ -93,6 +94,12 @@ const settingsGlobalMemoryRoute = createRoute({ component: GlobalMemorySettings, }); +const settingsExtensionsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/extensions", + component: GlobalExtensionsSettings, +}); + const settingsBundleEditorsRoute = createRoute({ getParentRoute: () => settingsRoute, path: "/bundle-editors", @@ -115,6 +122,7 @@ export const routeTree = rootRoute.addChildren([ settingsRoute.addChildren([ settingsIndexRoute, settingsGlobalMemoryRoute, + settingsExtensionsRoute, settingsBundleEditorsRoute, ]), ...(env.isDev ? [bundleUiHarnessRoute] : []), diff --git a/apps/web/src/routes/settings/SettingsLayout.tsx b/apps/web/src/routes/settings/SettingsLayout.tsx index 37a7a7e..e2c13dc 100644 --- a/apps/web/src/routes/settings/SettingsLayout.tsx +++ b/apps/web/src/routes/settings/SettingsLayout.tsx @@ -11,7 +11,10 @@ import { WorkArea } from "@/shared/ui/patterns/work-area"; import { SettingsHeader, SettingsNav } from "./settings-frame"; interface SettingsItem { - to: "/settings/global-memory" | "/settings/bundle-editors"; + to: + | "/settings/global-memory" + | "/settings/extensions" + | "/settings/bundle-editors"; label: string; icon: IconName; adminOnly: boolean; @@ -24,6 +27,12 @@ const SETTINGS_ITEMS: SettingsItem[] = [ icon: "Brain", adminOnly: false, }, + { + to: "/settings/extensions", + label: "Extensions", + icon: "Puzzle", + adminOnly: false, + }, { to: "/settings/bundle-editors", label: "Bundle editors", diff --git a/apps/web/src/routes/settings/sections/global-extensions/CatalogSearchStep.tsx b/apps/web/src/routes/settings/sections/global-extensions/CatalogSearchStep.tsx new file mode 100644 index 0000000..36a088f --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/CatalogSearchStep.tsx @@ -0,0 +1,123 @@ +import type { GlobalExtensionMeta } from "@tangent/shared/contracts"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Paragraph, Text } from "@tangent/ui-primitives/typography"; +import { type PropsWithChildren, useState } from "react"; + +import { useDebouncedValue } from "@/features/global-extensions/hooks/useDebouncedValue"; +import { useMarketplaceSearch } from "@/features/global-extensions/hooks/useMarketplaceSearch"; +import { ListRow } from "@/shared/ui/patterns/list-row"; +import { Pill } from "@/shared/ui/patterns/pill"; +import { TextInput } from "@/shared/ui/patterns/text-input"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +import type { InstallTarget } from "./installTarget"; + +// local primitive — bounded, scrollable result list with a visible thin scrollbar. +function ResultsScroll({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} + +interface CatalogSearchStepProps { + marketplaceId: string; + installed: GlobalExtensionMeta[]; + onSelect: (target: InstallTarget) => void; +} + +export function CatalogSearchStep({ + marketplaceId, + installed, + onSelect, +}: CatalogSearchStepProps) { + const [query, setQuery] = useState(""); + const debounced = useDebouncedValue(query.trim()); + const results = useMarketplaceSearch(marketplaceId, debounced); + + const installedNames = new Set(installed.map((ext) => ext.name)); + + return ( + + setQuery(event.target.value)} + placeholder="Search the Pi catalog..." + aria-label="Search the Pi catalog" + autoFocus + /> + + {results.isError ? ( + + Search failed: {results.error.message} + + ) : null} + + + {results.isPending ? ( + + Searching... + + ) : null} + + {results.data && results.data.length === 0 ? ( + + No Pi packages match. + + ) : null} + + {results.data && results.data.length > 0 ? ( + + {results.data.map((listing) => { + const alreadyInstalled = installedNames.has(listing.name); + return ( + + onSelect({ + kind: "source", + source: `${marketplaceId}:${listing.ref}`, + name: listing.name, + version: listing.version, + installed: alreadyInstalled, + }) + } + > + + + + + + {listing.name} + + + {listing.version ? ( + + v{listing.version} + + ) : null} + + {listing.description ? ( + + {listing.description} + + ) : null} + + + {alreadyInstalled ? ( + + Installed + + ) : null} + + ); + })} + + ) : null} + + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/ConfirmInstallStep.tsx b/apps/web/src/routes/settings/sections/global-extensions/ConfirmInstallStep.tsx new file mode 100644 index 0000000..90adcc8 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/ConfirmInstallStep.tsx @@ -0,0 +1,144 @@ +import { Button } from "@tangent/ui-primitives/button"; +import { Checkbox } from "@tangent/ui-primitives/checkbox"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Paragraph, Text } from "@tangent/ui-primitives/typography"; +import { type ReactNode, useState } from "react"; + +import { useInstallGlobalExtension } from "@/features/global-extensions/hooks/useInstallGlobalExtension"; +import { ListRow } from "@/shared/ui/patterns/list-row"; +import { Pill } from "@/shared/ui/patterns/pill"; +import { TextInput } from "@/shared/ui/patterns/text-input"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +import { type InstallTarget, parseTools } from "./installTarget"; + +interface ConfirmInstallStepProps { + target: InstallTarget; + onBack: () => void; + onClose: () => void; +} + +// local primitive — form field pairing a caption label with its control. +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function targetName(target: InstallTarget): string { + return target.kind === "file" ? target.file.name : target.name; +} + +export function ConfirmInstallStep({ + target, + onBack, + onClose, +}: ConfirmInstallStepProps) { + const install = useInstallGlobalExtension(); + const [idOverride, setIdOverride] = useState(""); + const [toolsText, setToolsText] = useState(""); + const [replace, setReplace] = useState( + target.kind === "source" && target.installed, + ); + + const submit = () => { + const shared = { + id: idOverride.trim() || undefined, + tools: parseTools(toolsText), + replace: replace || undefined, + }; + const input = + target.kind === "file" + ? ({ kind: "file", file: target.file, ...shared } as const) + : ({ kind: "source", source: target.source, ...shared } as const); + install.mutate(input, { onSuccess: onClose }); + }; + + return ( + + + + + + {targetName(target)} + + {target.kind === "source" && target.version ? ( + + v{target.version} + + ) : null} + {target.kind === "source" && target.installed ? ( + + Installed + + ) : null} + + + + + + + setIdOverride(event.target.value)} + placeholder="derived from the package name" + /> + + + + + setToolsText(event.target.value)} + placeholder="leave blank to auto-detect from the extension" + /> + + Left blank, the extension's own tools are detected on install and + can be trimmed afterward. + + + + + + + {install.isError ? ( + + Install failed: {install.error.message} + + ) : null} + + + + + + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/ExtensionList.tsx b/apps/web/src/routes/settings/sections/global-extensions/ExtensionList.tsx new file mode 100644 index 0000000..0863125 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/ExtensionList.tsx @@ -0,0 +1,159 @@ +import type { GlobalExtensionMeta } from "@tangent/shared/contracts"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Paragraph, Text } from "@tangent/ui-primitives/typography"; +import { useState } from "react"; + +import { useUninstallGlobalExtension } from "@/features/global-extensions/hooks/useUninstallGlobalExtension"; +import { IconButton } from "@/shared/ui/patterns/icon-button"; +import { ListRow } from "@/shared/ui/patterns/list-row"; +import { Pill } from "@/shared/ui/patterns/pill"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +import { ExtensionToolsEditor } from "./ExtensionToolsEditor"; + +interface ExtensionListProps { + extensions: GlobalExtensionMeta[]; + canManage: boolean; +} + +function resourceSummary(resources: GlobalExtensionMeta["resources"]): string { + const parts: string[] = []; + if (resources.extensions.length > 0) { + parts.push(`${resources.extensions.length} extensions`); + } + if (resources.skills.length > 0) { + parts.push(`${resources.skills.length} skills`); + } + if (resources.prompts.length > 0) { + parts.push(`${resources.prompts.length} prompts`); + } + return parts.join(", "); +} + +function ExtensionRow({ + extension, + canManage, +}: { + extension: GlobalExtensionMeta; + canManage: boolean; +}) { + const uninstall = useUninstallGlobalExtension(); + const [editing, setEditing] = useState(false); + + const onRemove = () => { + if (!window.confirm(`Uninstall ${extension.name}?`)) return; + uninstall.mutate(extension.id); + }; + + const summary = resourceSummary(extension.resources); + + return ( + + + + + + + + + {extension.name} + + {extension.version ? ( + + v{extension.version} + + ) : null} + + + {extension.id} + + + + + {extension.source.marketplace} + + + {extension.status} + + + + {extension.status === "error" && extension.error ? ( + + {extension.error} + + ) : null} + + {summary ? ( + + {summary} + + ) : null} + + {editing ? ( + setEditing(false)} + /> + ) : extension.tools.length > 0 ? ( + + {extension.tools.map((tool) => ( + + {tool} + + ))} + + ) : null} + + {uninstall.isError ? ( + + Failed to uninstall: {uninstall.error.message} + + ) : null} + + + + {canManage ? ( + + setEditing((prev) => !prev)} + /> + + + ) : null} + + ); +} + +export function ExtensionList({ extensions, canManage }: ExtensionListProps) { + if (extensions.length === 0) { + return ( + + No extensions installed yet. + + ); + } + + return ( + + {extensions.map((extension) => ( + + ))} + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/ExtensionToolsEditor.tsx b/apps/web/src/routes/settings/sections/global-extensions/ExtensionToolsEditor.tsx new file mode 100644 index 0000000..bed25a2 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/ExtensionToolsEditor.tsx @@ -0,0 +1,73 @@ +import { Button } from "@tangent/ui-primitives/button"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Paragraph } from "@tangent/ui-primitives/typography"; +import { type FormEvent, useState } from "react"; + +import { useUpdateGlobalExtension } from "@/features/global-extensions/hooks/useUpdateGlobalExtension"; +import { TextInput } from "@/shared/ui/patterns/text-input"; +import { Truncating } from "@/shared/ui/patterns/truncating"; + +interface ExtensionToolsEditorProps { + id: string; + tools: string[]; + onDone: () => void; +} + +function parseTools(raw: string): string[] { + return raw + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); +} + +export function ExtensionToolsEditor({ + id, + tools, + onDone, +}: ExtensionToolsEditorProps) { + const update = useUpdateGlobalExtension(); + const [value, setValue] = useState(tools.join(", ")); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + update.mutate( + { id, body: { tools: parseTools(value) } }, + { onSuccess: onDone }, + ); + }; + + return ( +
+ + + + setValue(event.target.value)} + placeholder="tool-one, tool-two" + aria-label={`Tools for ${id}`} + disabled={update.isPending} + /> + + + + + {update.isError ? ( + + Failed to save tools: {update.error.message} + + ) : null} + +
+ ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/FileStep.tsx b/apps/web/src/routes/settings/sections/global-extensions/FileStep.tsx new file mode 100644 index 0000000..95e6907 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/FileStep.tsx @@ -0,0 +1,55 @@ +import { Button } from "@tangent/ui-primitives/button"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Text } from "@tangent/ui-primitives/typography"; +import { useRef, useState } from "react"; + +import type { InstallTarget } from "./installTarget"; + +const FILE_ACCEPT = ".zip,.tgz,.tar,.gz,.ts,.js,.mjs,.cjs"; + +interface FileStepProps { + onSelect: (target: InstallTarget) => void; +} + +export function FileStep({ onSelect }: FileStepProps) { + const fileInputRef = useRef(null); + const [file, setFile] = useState(undefined); + + return ( + + { + setFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + + + {file ? file.name : "No file selected"} + + + + + + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/GlobalExtensionsSettings.tsx b/apps/web/src/routes/settings/sections/global-extensions/GlobalExtensionsSettings.tsx new file mode 100644 index 0000000..59e8567 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/GlobalExtensionsSettings.tsx @@ -0,0 +1,78 @@ +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { Heading, Paragraph } from "@tangent/ui-primitives/typography"; +import { useState } from "react"; + +import { useExtensionMarketplaces } from "@/features/global-extensions/hooks/useExtensionMarketplaces"; +import { useGlobalExtensions } from "@/features/global-extensions/hooks/useGlobalExtensions"; +import { useMyPermissions } from "@/features/user/hooks/useMyPermissions"; +import { Section } from "@/shared/ui/patterns/section"; + +import { ExtensionList } from "./ExtensionList"; +import { InstallExtensionButton } from "./InstallExtensionButton"; +import { InstallWizard } from "./InstallWizard"; + +function GlobalExtensionsContent({ canManage }: { canManage: boolean }) { + const { data, isLoading, error } = useGlobalExtensions(); + const marketplaces = useExtensionMarketplaces(); + const [activeId, setActiveId] = useState(null); + + if (error) { + return ( + + Failed to load extensions: {error.message} + + ); + } + if (isLoading || !data) { + return ( + + Loading extensions... + + ); + } + + const activeMarketplace = + marketplaces.data?.find((m) => m.id === activeId) ?? null; + + return ( + <> + {activeMarketplace ? ( + setActiveId(null)} + /> + ) : canManage && marketplaces.data ? ( + + + + ) : null} + +
+ +
+ + ); +} + +export function GlobalExtensionsSettings() { + const { isAdmin, isLoading } = useMyPermissions(); + + return ( + + + Extensions + + {isAdmin || isLoading + ? "Global packages applied to sessions that opt in. Changes take effect on each agent's next spawn." + : "Global packages applied to sessions that opt in. Only admins can install or remove them."} + + + + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/InstallExtensionButton.tsx b/apps/web/src/routes/settings/sections/global-extensions/InstallExtensionButton.tsx new file mode 100644 index 0000000..d6965ae --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/InstallExtensionButton.tsx @@ -0,0 +1,80 @@ +import { Button } from "@tangent/ui-primitives/button"; +import { ButtonGroup } from "@tangent/ui-primitives/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@tangent/ui-primitives/dropdown-menu"; +import { Icon } from "@tangent/ui-primitives/icon"; +import { InlineStack } from "@tangent/ui-primitives/layout"; +import { Text } from "@tangent/ui-primitives/typography"; + +import { marketplaceLabel } from "./marketplaceLabels"; + +interface Marketplace { + id: string; + canSearch: boolean; +} + +interface InstallExtensionButtonProps { + marketplaces: Marketplace[]; + disabled?: boolean; + onStart: (marketplaceId: string) => void; +} + +function defaultMarketplace( + marketplaces: Marketplace[], +): Marketplace | undefined { + return marketplaces.find((m) => m.id === "npm") ?? marketplaces[0]; +} + +export function InstallExtensionButton({ + marketplaces, + disabled, + onStart, +}: InstallExtensionButtonProps) { + const fallback = defaultMarketplace(marketplaces); + if (!fallback) return null; + + return ( + + + + + + + + {marketplaces.map((marketplace) => { + const { label, icon } = marketplaceLabel(marketplace.id); + return ( + onStart(marketplace.id)} + > + + + {label} + + + ); + })} + + + + ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/InstallWizard.tsx b/apps/web/src/routes/settings/sections/global-extensions/InstallWizard.tsx new file mode 100644 index 0000000..cc0468e --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/InstallWizard.tsx @@ -0,0 +1,64 @@ +import type { GlobalExtensionMeta } from "@tangent/shared/contracts"; +import { useState } from "react"; + +import { IconButton } from "@/shared/ui/patterns/icon-button"; +import { Section } from "@/shared/ui/patterns/section"; + +import { CatalogSearchStep } from "./CatalogSearchStep"; +import { ConfirmInstallStep } from "./ConfirmInstallStep"; +import { FileStep } from "./FileStep"; +import type { InstallTarget } from "./installTarget"; +import { marketplaceLabel } from "./marketplaceLabels"; +import { SourceRefStep } from "./SourceRefStep"; + +interface Marketplace { + id: string; + canSearch: boolean; +} + +interface InstallWizardProps { + marketplace: Marketplace; + installed: GlobalExtensionMeta[]; + onClose: () => void; +} + +type Step = { kind: "select" } | { kind: "confirm"; target: InstallTarget }; + +export function InstallWizard({ + marketplace, + installed, + onClose, +}: InstallWizardProps) { + const { label } = marketplaceLabel(marketplace.id); + const [step, setStep] = useState({ kind: "select" }); + + const select = (target: InstallTarget) => + setStep({ kind: "confirm", target }); + + return ( +
+ } + > + {step.kind === "confirm" ? ( + setStep({ kind: "select" })} + onClose={onClose} + /> + ) : marketplace.canSearch ? ( + + ) : marketplace.id === "file" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/SourceRefStep.tsx b/apps/web/src/routes/settings/sections/global-extensions/SourceRefStep.tsx new file mode 100644 index 0000000..251c9c7 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/SourceRefStep.tsx @@ -0,0 +1,53 @@ +import { Button } from "@tangent/ui-primitives/button"; +import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; +import { type FormEvent, useState } from "react"; + +import { TextInput } from "@/shared/ui/patterns/text-input"; + +import type { InstallTarget } from "./installTarget"; + +interface SourceRefStepProps { + marketplaceId: string; + onSelect: (target: InstallTarget) => void; +} + +function refPlaceholder(marketplaceId: string): string { + if (marketplaceId === "git") return "github.com/user/repo@v1"; + if (marketplaceId === "npm") return "pi-foo@1.2.0"; + return "package reference"; +} + +export function SourceRefStep({ marketplaceId, onSelect }: SourceRefStepProps) { + const [ref, setRef] = useState(""); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const trimmed = ref.trim(); + if (!trimmed) return; + onSelect({ + kind: "source", + source: `${marketplaceId}:${trimmed}`, + name: trimmed, + installed: false, + }); + }; + + return ( +
+ + setRef(event.target.value)} + placeholder={refPlaceholder(marketplaceId)} + aria-label={`${marketplaceId} package reference`} + autoFocus + /> + + + + +
+ ); +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/installTarget.ts b/apps/web/src/routes/settings/sections/global-extensions/installTarget.ts new file mode 100644 index 0000000..9cf4d48 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/installTarget.ts @@ -0,0 +1,18 @@ +/** The item chosen in step 1, carried into the confirm step. */ +export type InstallTarget = + | { + kind: "source"; + source: string; + name: string; + version?: string; + installed: boolean; + } + | { kind: "file"; file: File }; + +export function parseTools(raw: string): string[] | undefined { + const tools = raw + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} diff --git a/apps/web/src/routes/settings/sections/global-extensions/marketplaceLabels.ts b/apps/web/src/routes/settings/sections/global-extensions/marketplaceLabels.ts new file mode 100644 index 0000000..06575f2 --- /dev/null +++ b/apps/web/src/routes/settings/sections/global-extensions/marketplaceLabels.ts @@ -0,0 +1,16 @@ +import type { IconName } from "@tangent/ui-primitives/icon"; + +interface MarketplaceLabel { + label: string; + icon: IconName; +} + +const LABELS: Record = { + npm: { label: "Pi catalog", icon: "Package" }, + git: { label: "Git repository", icon: "GitBranch" }, + file: { label: "Upload file", icon: "Upload" }, +}; + +export function marketplaceLabel(id: string): MarketplaceLabel { + return LABELS[id] ?? { label: id, icon: "Package" }; +}