diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
index 06747e03..63c64568 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
@@ -1,5 +1,6 @@
import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components";
+import ActionMenu from "@/shared/components/ActionMenu";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
@@ -16,8 +17,30 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
: undefined;
const deleteModel = onRemove || (isCustom ? onDeleteAlias : onDisable);
const deletesPermanently = !!deleteModel;
-
- const actionButtonClass = "inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-background hover:text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50";
+ const actionItems = [
+ ...(onTest ? [{
+ id: "test",
+ icon: isTesting ? "progress_activity" : "science",
+ label: isTesting ? "Testing model…" : "Test model",
+ onSelect: onTest,
+ disabled: isTesting,
+ spinning: isTesting,
+ }] : []),
+ {
+ id: "copy",
+ icon: copied === `model-${model.id}` ? "check" : "content_copy",
+ label: copied === `model-${model.id}` ? "Model ID copied" : "Copy model ID",
+ onSelect: () => onCopy(displayModel, `model-${model.id}`),
+ },
+ ...(deleteModel ? [{
+ id: "delete",
+ icon: "delete",
+ label: deletesPermanently ? "Permanently delete model" : "Hide model",
+ onSelect: deleteModel,
+ danger: true,
+ dividerBefore: true,
+ }] : []),
+ ];
return (
@@ -35,44 +58,8 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
-
- {onTest && (
-
- )}
-
- {deleteModel &&
}
- {deleteModel && (
-
- )}
+
diff --git a/src/shared/components/ActionMenu.js b/src/shared/components/ActionMenu.js
new file mode 100644
index 00000000..156387aa
--- /dev/null
+++ b/src/shared/components/ActionMenu.js
@@ -0,0 +1,128 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import PropTypes from "prop-types";
+
+const MENU_WIDTH = 192;
+const VIEWPORT_GUTTER = 12;
+
+/**
+ * Compact, reusable overflow menu for contextual row actions.
+ */
+export default function ActionMenu({ ariaLabel, items, title = "More options" }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [opensRight, setOpensRight] = useState(true);
+ const menuRef = useRef(null);
+ const triggerRef = useRef(null);
+ const menuId = useId();
+
+ const updateHorizontalPlacement = () => {
+ const triggerRect = triggerRef.current?.getBoundingClientRect();
+ if (!triggerRect) return;
+
+ setOpensRight(triggerRect.left + MENU_WIDTH <= window.innerWidth - VIEWPORT_GUTTER);
+ };
+
+ useEffect(() => {
+ if (!isOpen) return undefined;
+
+ const handlePointerDown = (event) => {
+ if (!menuRef.current?.contains(event.target)) setIsOpen(false);
+ };
+
+ const handleKeyDown = (event) => {
+ if (event.key === "Escape") {
+ setIsOpen(false);
+ menuRef.current?.querySelector("button")?.focus();
+ }
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("resize", updateHorizontalPlacement);
+ updateHorizontalPlacement();
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("resize", updateHorizontalPlacement);
+ };
+ }, [isOpen]);
+
+ const handleSelect = (item) => {
+ if (item.disabled) return;
+ setIsOpen(false);
+ item.onSelect();
+ };
+
+ return (
+
+
+
+ {isOpen && (
+
+ )}
+
+ );
+}
+
+ActionMenu.propTypes = {
+ ariaLabel: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ onSelect: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ dividerBefore: PropTypes.bool,
+ danger: PropTypes.bool,
+ spinning: PropTypes.bool,
+ })).isRequired,
+ title: PropTypes.string,
+};
\ No newline at end of file
diff --git a/src/shared/components/index.js b/src/shared/components/index.js
index 0941377e..101a121a 100644
--- a/src/shared/components/index.js
+++ b/src/shared/components/index.js
@@ -21,6 +21,7 @@ export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as HeaderMenu } from "./HeaderMenu";
+export { default as ActionMenu } from "./ActionMenu";
export { default as ChangelogModal } from "./ChangelogModal";
export { default as RequestLogger } from "./RequestLogger";
export { default as KiroAuthModal } from "./KiroAuthModal";