Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/web/src/features/global-extensions/api/globalExtensionsApi.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<GlobalExtensionMeta[]> {
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<MarketplaceListing[]> {
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<GlobalExtensionMeta> {
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<GlobalExtensionMeta> {
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<GlobalExtensionMeta> {
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<void> {
const res = await apiFetch(`/api/extensions/${encodeURIComponent(id)}`, {
method: "DELETE",
});
await ensureOk(res);
}
13 changes: 13 additions & 0 deletions apps/web/src/features/global-extensions/hooks/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";

/** Returns `value` delayed by `delayMs`, resetting the timer on every change. */
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);

return debounced;
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
@@ -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<GlobalExtensionMeta> {
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(),
});
},
});
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
@@ -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(),
});
},
});
}
Original file line number Diff line number Diff line change
@@ -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(),
});
},
});
}
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/routes/routeTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -115,6 +122,7 @@ export const routeTree = rootRoute.addChildren([
settingsRoute.addChildren([
settingsIndexRoute,
settingsGlobalMemoryRoute,
settingsExtensionsRoute,
settingsBundleEditorsRoute,
]),
...(env.isDev ? [bundleUiHarnessRoute] : []),
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/routes/settings/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand Down
Loading
Loading