diff --git a/frontend/src/components/PluginsPanel.jsx b/frontend/src/components/PluginsPanel.jsx
index 6f5dec6..59e65cd 100644
--- a/frontend/src/components/PluginsPanel.jsx
+++ b/frontend/src/components/PluginsPanel.jsx
@@ -11,10 +11,49 @@ const PLUGIN_ICONS = {
jsonformat: BracesIcon,
};
+// Helper badge renderer for compatibility tags (#597)
+function CompatibilityBadge({ compatibility }) {
+ if (!compatibility) return null;
+
+ const badges = Array.isArray(compatibility)
+ ? compatibility
+ : typeof compatibility === "object"
+ ? Object.values(compatibility)
+ : [compatibility];
+
+ return (
+
+ {badges.map((badge, idx) => {
+ const isLocal = String(badge).toLowerCase().includes("local") || String(badge).toLowerCase().includes("v1");
+ const badgeStyle = isLocal
+ ? "bg-emerald-950/60 text-emerald-400 border-emerald-800/60"
+ : "bg-blue-950/60 text-blue-400 border-blue-800/60";
+
+ return (
+
+ {badge}
+
+ );
+ })}
+
+ );
+}
+
export default function PluginsPanel({ sessionId, onClose }) {
const [plugins, setPlugins] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
const [selected, setSelected] = useState(null);
- const [input, setInput] = useState("");
+
+ // Persistent input state initialized from localStorage (#596)
+ const [input, setInput] = useState(() => {
+ if (!sessionId) return "";
+ return localStorage.getItem(`localmind_plugin_draft_${sessionId}`) || "";
+ });
+
const [output, setOutput] = useState("");
const [running, setRunning] = useState(false);
const [loading, setLoading] = useState(false);
@@ -22,10 +61,23 @@ export default function PluginsPanel({ sessionId, onClose }) {
const [copied, setCopied] = useState(false);
const [logs, setLogs] = useState([]);
- // State for contextual menu and action toast feedback (#605)
+ // Persistent Pinned / Favorite Plugin IDs (#601)
+ const [pinnedIds, setPinnedIds] = useState(() => {
+ try {
+ const saved = localStorage.getItem(`plugins-panel-pinned:${sessionId}`);
+ return saved ? JSON.parse(saved) : [];
+ } catch (e) {
+ return [];
+ }
+ });
+
+ // State for contextual menu and action toast feedback (#602, #605)
const [activeMenuId, setActiveMenuId] = useState(null);
const [notification, setNotification] = useState("");
+ // Changelog modal state (#603)
+ const [changelogPlugin, setChangelogPlugin] = useState(null);
+
// Persistence: View collapsed state (#592)
const [isCollapsed, setIsCollapsed] = useState(() => {
try {
@@ -45,6 +97,15 @@ export default function PluginsPanel({ sessionId, onClose }) {
}
});
+ // Sync pinned plugin state to localStorage (#601)
+ useEffect(() => {
+ try {
+ localStorage.setItem(`plugins-panel-pinned:${sessionId}`, JSON.stringify(pinnedIds));
+ } catch (e) {
+ console.warn("localStorage write blocked:", e);
+ }
+ }, [pinnedIds, sessionId]);
+
// Sync collapsed state to localStorage (#592)
useEffect(() => {
try {
@@ -96,7 +157,7 @@ export default function PluginsPanel({ sessionId, onClose }) {
.finally(() => setLoading(false));
fetchLogs();
- }, [selectedPluginId]);
+ }, [selectedPluginId, sessionId]);
function handleSelectPlugin(plugin) {
setSelected(plugin);
@@ -106,11 +167,37 @@ export default function PluginsPanel({ sessionId, onClose }) {
setCopied(false);
}
- // Close contextual action menu on global click or Escape key
+ function togglePin(e, pluginId) {
+ e.stopPropagation();
+ setPinnedIds((prev) =>
+ prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
+ );
+ }
+
+ // Re-sync input draft whenever sessionId changes (#596)
+ useEffect(() => {
+ if (!sessionId) return;
+ setInput(localStorage.getItem(`localmind_plugin_draft_${sessionId}`) || "");
+ }, [sessionId]);
+
+ // Persist plugin draft input to localStorage on edit (#596)
+ useEffect(() => {
+ if (!sessionId) return;
+ if (input) {
+ localStorage.setItem(`localmind_plugin_draft_${sessionId}`, input);
+ } else {
+ localStorage.removeItem(`localmind_plugin_draft_${sessionId}`);
+ }
+ }, [input, sessionId]);
+
+ // Close menus and modals on outside click or Escape key (#603)
useEffect(() => {
const handleGlobalClick = () => setActiveMenuId(null);
const handleKeyDown = (e) => {
- if (e.key === "Escape") setActiveMenuId(null);
+ if (e.key === "Escape") {
+ setActiveMenuId(null);
+ setChangelogPlugin(null);
+ }
};
window.addEventListener("click", handleGlobalClick);
window.addEventListener("keydown", handleKeyDown);
@@ -162,6 +249,12 @@ export default function PluginsPanel({ sessionId, onClose }) {
}
};
+ function handleOpenChangelog(e, plugin) {
+ e.stopPropagation();
+ setChangelogPlugin(plugin);
+ setActiveMenuId(null);
+ }
+
const toggleContextMenu = (e, pluginId) => {
e.stopPropagation();
setActiveMenuId((prev) => (prev === pluginId ? null : pluginId));
@@ -178,6 +271,9 @@ export default function PluginsPanel({ sessionId, onClose }) {
if (r.success) {
setOutput(r.output);
await fetchLogs();
+ if (sessionId) {
+ localStorage.removeItem(`localmind_plugin_draft_${sessionId}`);
+ }
} else {
setError(r.error || "Plugin failed");
}
@@ -188,6 +284,24 @@ export default function PluginsPanel({ sessionId, onClose }) {
}
}
+ // Sort plugins with pinned/favorites at the top (#601)
+ const sortedPlugins = [...plugins].sort((a, b) => {
+ const isAPinned = pinnedIds.includes(a.id);
+ const isBPinned = pinnedIds.includes(b.id);
+ if (isAPinned && !isBPinned) return -1;
+ if (!isAPinned && isBPinned) return 1;
+ return 0;
+ });
+
+ // Filter plugins in real-time by search query (#600)
+ const filteredPlugins = sortedPlugins.filter((p) => {
+ if (!searchQuery.trim()) return true;
+ const q = searchQuery.toLowerCase();
+ const nameMatch = p.name?.toLowerCase().includes(q);
+ const descMatch = p.description?.toLowerCase().includes(q);
+ return nameMatch || descMatch;
+ });
+
const handleCopy = async () => {
if (!output) return;
try {
@@ -276,67 +390,111 @@ export default function PluginsPanel({ sessionId, onClose }) {
)}
- {/* Collapsible Panel Section (#592) */}
+ {/* Collapsible Panel Section */}
{!isCollapsed && (
<>
- {/* Plugin selector row with contextual dropdown menus (#605) */}
-
- {plugins.map((p) => {
- const Icon = PLUGIN_ICONS[p.icon] || PlugIcon;
- const isMenuOpen = activeMenuId === p.id;
-
- return (
-
handleSelectPlugin(p)}
- data-testid={`plugin-btn-${p.id}`}
- className={`relative text-xs px-3.5 py-2 md:py-1.5 rounded-lg border transition font-medium flex items-center gap-1.5 cursor-pointer touch-manipulation select-none
- ${selected?.id === p.id ? "border-purple-500 bg-purple-900/30 text-purple-300 shadow-sm shadow-purple-500/10" : "border-gray-700 text-gray-400 hover:bg-gray-800"}`}
- >
-
-
{p.name}
-
- {/* Context Menu Trigger Button */}
-
{/* Plugin Input/Output Area OR Empty-State Guidance */}
{selected ? (
-
{selected.description}
+
+
{selected.description}
+ {selected.compatibility && (
+
+ Compatibility:
+
+
+ )}
+
- {/* Output block with copy feedback button (#594) */}
+ {/* Output block with copy feedback button */}
{output && (
@@ -377,7 +535,7 @@ export default function PluginsPanel({ sessionId, onClose }) {
)}
) : (
- /* Empty State Guidance Card (#587) */
+ /* Empty State Guidance Card */
No Plugin Selected
@@ -423,6 +581,55 @@ export default function PluginsPanel({ sessionId, onClose }) {
>
)}
+
+ {/* Changelog Modal (#603) */}
+ {changelogPlugin && (
+
setChangelogPlugin(null)}
+ data-testid="changelog-modal"
+ >
+
e.stopPropagation()}
+ >
+
+
+ {changelogPlugin.name}
+
+ Changelog
+
+
+ setChangelogPlugin(null)}
+ className="text-gray-500 hover:text-white transition font-bold text-lg leading-none p-1"
+ aria-label="Close changelog modal"
+ >
+ ×
+
+
+
+
+ {changelogPlugin.changelog && changelogPlugin.changelog.length > 0 ? (
+ changelogPlugin.changelog.map((item, idx) => (
+
+
+ {item.version}
+ {item.date}
+
+
{item.changes}
+
+ ))
+ ) : (
+
+ No changelog preview available for {changelogPlugin.name}.
+
+ )}
+
+
+
+ )}
);
}
\ No newline at end of file
diff --git a/frontend/src/components/PluginsPanel.test.jsx b/frontend/src/components/PluginsPanel.test.jsx
index 4d047d0..dc247a0 100644
--- a/frontend/src/components/PluginsPanel.test.jsx
+++ b/frontend/src/components/PluginsPanel.test.jsx
@@ -26,8 +26,21 @@ vi.mock("./Icons", () => ({
}));
const mockPluginsList = [
- { id: "calculator", name: "Calculator", icon: "calculator", description: "Performs math evaluation" },
- { id: "summarizer", name: "Summarizer", icon: "summarizer", description: "Summarizes provided text" },
+ {
+ id: "calculator",
+ name: "Calculator",
+ icon: "calculator",
+ description: "Basic math evaluation",
+ changelog: [
+ { version: "v1.1.0", date: "2026-06-01", changes: "Added scientific mode." },
+ ],
+ },
+ {
+ id: "summarizer",
+ name: "Summarizer",
+ icon: "summarizer",
+ description: "Summarize provided text",
+ },
];
describe("PluginsPanel Interaction Tests (#595)", () => {
@@ -60,7 +73,7 @@ describe("PluginsPanel Interaction Tests (#595)", () => {
fireEvent.click(screen.getByTestId("plugin-btn-calculator"));
expect(screen.getByTestId("plugin-workspace")).toBeInTheDocument();
- expect(screen.getByText("Performs math evaluation")).toBeInTheDocument();
+ expect(screen.getByText("Basic math evaluation")).toBeInTheDocument();
expect(screen.getByTestId("plugin-input-textarea")).toBeInTheDocument();
expect(screen.getByTestId("run-plugin-btn")).toBeDisabled();
});
@@ -171,6 +184,63 @@ describe("PluginsPanel Interaction Tests (#595)", () => {
});
});
+describe("PluginsPanel Changelog Previews (#603)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.getPlugins.mockResolvedValue({ plugins: mockPluginsList });
+ api.getPluginLogs.mockResolvedValue({ logs: [] });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders plugin list correctly", async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Calculator")).toBeInTheDocument();
+ expect(screen.getByText("Summarizer")).toBeInTheDocument();
+ });
+ });
+
+ test("opens changelog preview modal when 'View Changelog' is clicked", async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Calculator")).toBeInTheDocument();
+ });
+
+ const optionsBtn = screen.getByLabelText("Options for Calculator");
+ fireEvent.click(optionsBtn);
+
+ const changelogBtn = screen.getByText("View Changelog");
+ fireEvent.click(changelogBtn);
+
+ expect(screen.getByTestId("changelog-modal")).toBeInTheDocument();
+ expect(screen.getByText("v1.1.0")).toBeInTheDocument();
+ expect(screen.getByText("Added scientific mode.")).toBeInTheDocument();
+ });
+
+ test("displays fallback message when plugin has no changelog entries", async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Summarizer")).toBeInTheDocument();
+ });
+
+ const optionsBtn = screen.getByLabelText("Options for Summarizer");
+ fireEvent.click(optionsBtn);
+
+ const changelogBtn = screen.getByText("View Changelog");
+ fireEvent.click(changelogBtn);
+
+ expect(
+ screen.getByText(/No changelog preview available for Summarizer/i)
+ ).toBeInTheDocument();
+ });
+});
+
describe("PluginsPanel Export & Share Suite (#605)", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -228,6 +298,44 @@ describe("PluginsPanel Export & Share Suite (#605)", () => {
});
});
+describe("PluginsPanel Favorite & Pin Support (#601)", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ api.getPlugins.mockResolvedValue({ plugins: mockPluginsList });
+ api.getPluginLogs.mockResolvedValue({ logs: [] });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("toggles pin state and saves to localStorage", async () => {
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Summarizer")).toBeInTheDocument();
+ });
+
+ const pinBtn = screen.getByLabelText("Pin Summarizer");
+ fireEvent.click(pinBtn);
+
+ expect(screen.getByLabelText("Unpin Summarizer")).toBeInTheDocument();
+ expect(JSON.parse(localStorage.getItem("plugins-panel-pinned:session-601"))).toContain("summarizer");
+ });
+
+ test("restores pinned favorites from localStorage on mount", async () => {
+ localStorage.setItem("plugins-panel-pinned:session-601", JSON.stringify(["summarizer"]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Unpin Summarizer")).toBeInTheDocument();
+ expect(screen.getByLabelText("Pin Calculator")).toBeInTheDocument();
+ });
+ });
+});
+
describe("PluginsPanel View State & Persistence Suite (#592)", () => {
let store = {};
@@ -292,13 +400,13 @@ describe("PluginsPanel View State & Persistence Suite (#592)", () => {
render(
);
await waitFor(() => {
- expect(screen.getByText("Summarizes provided text")).toBeInTheDocument();
+ expect(screen.getByText("Summarize provided text")).toBeInTheDocument();
});
const calcBtn = screen.getByText("Calculator");
fireEvent.click(calcBtn);
expect(localStorage.setItem).toHaveBeenCalledWith("plugins-panel-selected:test-session-4", "calculator");
- expect(screen.getByText("Performs math evaluation")).toBeInTheDocument();
+ expect(screen.getByText("Basic math evaluation")).toBeInTheDocument();
});
});
\ No newline at end of file