import {
IconDotsVertical,
IconEye,
IconEyeOff,
IconPlus,
IconTool,
IconTrash,
} from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { Link } from "react-router";
import { extensionPath } from "../../extensions/path.js";
import { sendToAgentChat } from "../agent-chat.js";
import { AgentToggleButton } from "../AgentPanel.js";
import { agentNativePath } from "../api-path.js";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu.js";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "../components/ui/popover.js";
import { PromptComposer } from "../composer/index.js";
import { useT } from "../i18n.js";
import { cn } from "../utils.js";
import {
deleteOrHideExtension,
invalidateExtensionRemoval,
} from "./delete-extension.js";
import {
TOOLS_ORDER_CHANGE_EVENT,
applyToolsOrder,
getToolsOrder,
} from "./extension-order.js";
import { ExtensionQueryErrorState } from "./ExtensionQueryErrorState.js";
interface Extension {
id: string;
name: string;
description?: string;
icon?: string;
canDelete?: boolean;
globallyHidden?: boolean;
source?: {
mode?: "database" | "local-files";
entryPath?: string;
};
}
let lastCreateSubmission: { prompt: string; at: number } | null = null;
function submitCreateTool(
prompt: string,
messageForPrompt: (prompt: string) => string,
) {
const trimmed = prompt.trim();
if (!trimmed) return;
const now = Date.now();
if (
lastCreateSubmission &&
lastCreateSubmission.prompt === trimmed &&
now - lastCreateSubmission.at < 2_000
) {
return;
}
lastCreateSubmission = { prompt: trimmed, at: now };
sendToAgentChat({
message: messageForPrompt(trimmed),
submit: true,
openSidebar: true,
newTab: true,
});
}
function CreateToolInput({ className }: { className?: string }) {
const t = useT();
return (
{t("extensions.whatShouldItDo")}
submitCreateTool(text, (prompt) =>
t("extensions.createPrompt", { prompt }),
)
}
/>
);
}
export interface ExtensionsListPageProps {
/** Skip the standalone extensions navigation state when embedded in Settings. */
embedded?: boolean;
}
export function ExtensionsListPage({
embedded = false,
}: ExtensionsListPageProps = {}) {
const t = useT();
const [showCreate, setShowCreate] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [showGloballyHidden, setShowGloballyHidden] = useState(false);
const queryClient = useQueryClient();
const [toolOrderState, setToolOrderState] = useState(() =>
typeof window !== "undefined" ? getToolsOrder() : [],
);
useEffect(() => {
if (embedded) return;
fetch(agentNativePath("/_agent-native/application-state/navigation"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ view: "extensions" }),
}).catch(() => {});
}, [embedded]);
useEffect(() => {
if (typeof window === "undefined") return;
const syncOrder = () => setToolOrderState(getToolsOrder());
window.addEventListener(TOOLS_ORDER_CHANGE_EVENT, syncOrder);
window.addEventListener("storage", syncOrder);
return () => {
window.removeEventListener(TOOLS_ORDER_CHANGE_EVENT, syncOrder);
window.removeEventListener("storage", syncOrder);
};
}, []);
const extensionsQuery = useQuery({
queryKey: ["extensions", { includeGloballyHidden: showGloballyHidden }],
queryFn: async () => {
const res = await fetch(
agentNativePath(
showGloballyHidden
? "/_agent-native/extensions?includeGloballyHidden=true"
: "/_agent-native/extensions",
),
);
if (!res.ok) throw new Error(`Failed to load extensions (${res.status})`);
return res.json();
},
});
const extensions = extensionsQuery.data;
const toolList =
toolOrderState.length > 0
? applyToolsOrder(extensions ?? [], toolOrderState)
: (extensions ?? []);
const handleCreate = (text: string) => {
submitCreateTool(text, (prompt) =>
t("extensions.createPrompt", { prompt }),
);
setShowCreate(false);
};
const handleDelete = async (extension: Extension) => {
setDeletingId(extension.id);
const previous = queryClient.getQueryData(["extensions"]);
queryClient.setQueryData(["extensions"], (old) =>
(old ?? []).filter((item) => item.id !== extension.id),
);
try {
await deleteOrHideExtension(extension);
invalidateExtensionRemoval(queryClient, extension.id);
} catch {
if (previous) queryClient.setQueryData(["extensions"], previous);
} finally {
setDeletingId(null);
setConfirmDeleteId(null);
queryClient.invalidateQueries({ queryKey: ["extensions"] });
}
};
const handleGlobalHideToggle = async (extension: Extension) => {
setConfirmDeleteId(null);
const action = extension.globallyHidden ? "global-unhide" : "global-hide";
try {
await fetch(
agentNativePath(`/_agent-native/extensions/${extension.id}/${action}`),
{ method: "POST" },
);
} finally {
queryClient.invalidateQueries({ queryKey: ["extensions"] });
}
};
return (
{!embedded ? (
{t("extensions.title")}
) : null}
{t("extensions.newExtensionTitle")}
setShowGloballyHidden(Boolean(checked))
}
>
{t("extensions.showHidden")}
{!embedded ?
: null}
{extensionsQuery.isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : extensionsQuery.isError ? (
void extensionsQuery.refetch()}
retrying={extensionsQuery.isFetching}
/>
) : toolList.length === 0 ? (
{t("extensions.emptyTitle")}
{t("extensions.emptyDescription")}
) : (
{toolList.map((extension) => {
const isLocalExtension = extension.source?.mode === "local-files";
return (
{extension.globallyHidden && (
)}
{extension.name}
{extension.description && (
{extension.description}
)}
{isLocalExtension && (
{t("extensions.localFile")}
)}
setConfirmDeleteId(open ? extension.id : null)
}
>
{!isLocalExtension && extension.canDelete !== false && (
)}
{isLocalExtension ? (
{t("extensions.localFileDescription", {
entryPath:
extension.source?.entryPath ?? "local files",
})}
) : (
{extension.canDelete === false
? t("extensions.removeQuestion", {
name: extension.name,
})
: t("extensions.deleteQuestion", {
name: extension.name,
})}{" "}
{extension.canDelete === false
? t("extensions.hideForYouDescription")
: t("extensions.removeEverywhereDescription")}
)}
);
})}
)}
);
}