Feat: initial plugin system (Frontend only)

This commit is contained in:
simosmik
2026-03-05 11:27:45 +00:00
parent f4615dfca3
commit 6517ec4ecd
19 changed files with 1262 additions and 53 deletions

View File

@@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { PluginsProvider } from './contexts/PluginsContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -15,8 +16,9 @@ export default function App() {
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<PluginsProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
@@ -25,8 +27,9 @@ export default function App() {
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</TaskMasterProvider>
</TasksSettingsProvider>
</PluginsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -1,43 +1,48 @@
import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import React, { useState, useRef, useEffect } from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { usePlugins } from '../contexts/PluginsContext';
const PLUGIN_ICON_MAP = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef(null);
const navItems = [
{
id: 'chat',
icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files')
},
{
id: 'git',
icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git')
},
...(shouldShowTasksTab ? [{
id: 'tasks',
icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks')
}] : [])
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
const isPluginActive = activeTab.startsWith('plugin:');
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e) => {
if (moreRef.current && !moreRef.current.contains(e.target)) {
setMoreOpen(false);
}
};
document.addEventListener('pointerdown', handleTap);
return () => document.removeEventListener('pointerdown', handleTap);
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name) => {
setActiveTab(`plugin:${name}`);
setMoreOpen(false);
};
const coreItems = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
];
return (
@@ -48,17 +53,17 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
{navItems.map((item) => {
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
setActiveTab(item.id);
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
isActive
@@ -81,6 +86,62 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl w-full relative touch-manipulation transition-all duration-200 active:scale-95 ${
isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
More
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="absolute bottom-full mb-2 right-0 min-w-[180px] py-1.5 rounded-xl border border-border/40 bg-popover shadow-lg z-[60] animate-in fade-in slide-in-from-bottom-2 duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex items-center gap-2.5 w-full px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'text-primary bg-primary/8'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView';
import TaskMasterPanel from './subcomponents/TaskMasterPanel';
import PluginTabContent from '../../plugins/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
@@ -158,6 +159,16 @@ function MainContent({
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
{activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden">
<PluginTabContent
pluginName={activeTab.replace('plugin:', '')}
selectedProject={selectedProject}
selectedSession={selectedSession}
/>
</div>
)}
</div>
<EditorSidebar

View File

@@ -1,8 +1,38 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import {
MessageSquare,
Terminal,
Folder,
GitBranch,
ClipboardCheck,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import Tooltip from '../../../Tooltip';
import type { AppTab } from '../../../../types/app';
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlugins } from '../../../../contexts/PluginsContext';
// Map of icon names plugins can reference in their manifest
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle,
Box,
Database,
Globe,
Terminal,
Wrench,
Zap,
BarChart3,
Folder,
MessageSquare,
GitBranch,
};
type MainContentTabSwitcherProps = {
activeTab: AppTab;
@@ -12,7 +42,8 @@ type MainContentTabSwitcherProps = {
type TabDefinition = {
id: AppTab;
labelKey: string;
labelKey?: string;
label?: string;
icon: LucideIcon;
};
@@ -35,17 +66,29 @@ export default function MainContentTabSwitcher({
shouldShowTasksTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const builtInTabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const pluginTabs: TabDefinition[] = plugins
.filter((p) => p.enabled)
.map((p) => ({
id: `plugin:${p.name}` as AppTab,
label: p.displayName,
icon: PLUGIN_ICON_MAP[p.icon] || Puzzle,
}));
const tabs = [...builtInTabs, ...pluginTabs];
return (
<div className="inline-flex items-center bg-muted/60 rounded-lg p-[3px] gap-[2px]">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.id === activeTab;
const displayLabel = tab.labelKey ? t(tab.labelKey) : tab.label || '';
return (
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
<Tooltip key={tab.id} content={displayLabel} position="bottom">
<button
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 px-2.5 py-[5px] text-sm font-medium rounded-md transition-all duration-150 ${
@@ -55,7 +98,7 @@ export default function MainContentTabSwitcher({
}`}
>
<Icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
<span className="hidden lg:inline">{displayLabel}</span>
</button>
</Tooltip>
);

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
type MainContentTitleProps = {
activeTab: AppTab;
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
shouldShowTasksTab: boolean;
};
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
if (activeTab.startsWith('plugin:') && pluginDisplayName) {
return pluginDisplayName;
}
if (activeTab === 'files') {
return t('mainContent.projectFiles');
}
@@ -40,6 +45,11 @@ export default function MainContentTitle({
shouldShowTasksTab,
}: MainContentTitleProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const pluginDisplayName = activeTab.startsWith('plugin:')
? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
: undefined;
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
const showChatNewSession = activeTab === 'chat' && !selectedSession;
@@ -68,7 +78,7 @@ export default function MainContentTitle({
) : (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">
{getTabTitle(activeTab, shouldShowTasksTab, t)}
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>

View File

@@ -0,0 +1,216 @@
import { useState } from 'react';
import { Puzzle, Trash2, RefreshCw, Power, PowerOff, GitBranch, Loader2 } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext';
import { Button } from '../ui/button';
export default function PluginSettingsTab() {
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } = usePlugins();
const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<{ name: string; message: string } | null>(null);
const handleUpdate = async (name: string) => {
setUpdatingPlugin(name);
setUpdateError(null);
const result = await updatePlugin(name);
if (!result.success) {
setUpdateError({ name, message: result.error || 'Update failed' });
}
setUpdatingPlugin(null);
};
const handleInstall = async () => {
if (!gitUrl.trim()) return;
setInstalling(true);
setInstallError(null);
const result = await installPlugin(gitUrl.trim());
if (result.success) {
setGitUrl('');
} else {
setInstallError(result.error || 'Installation failed');
}
setInstalling(false);
};
const handleUninstall = async (name: string) => {
if (confirmUninstall !== name) {
setConfirmUninstall(name);
return;
}
await uninstallPlugin(name);
setConfirmUninstall(null);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
Plugins
</h3>
<p className="text-sm text-muted-foreground mt-1">
Extend the app with custom tab plugins. Place plugins in{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">~/.claude-code-ui/plugins/</code>{' '}
or install from a git repository.
</p>
</div>
{/* Install from Git */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Install from Git</span>
</div>
<div className="flex gap-2">
<input
type="text"
value={gitUrl}
onChange={(e) => {
setGitUrl(e.target.value);
setInstallError(null);
}}
placeholder="https://github.com/user/my-plugin.git"
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
}}
/>
<Button
onClick={handleInstall}
disabled={installing || !gitUrl.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white"
>
{installing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Install'
)}
</Button>
</div>
{installError && (
<p className="text-sm text-red-500 mt-2">{installError}</p>
)}
</div>
{/* Plugin List */}
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading plugins...
</div>
) : plugins.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Puzzle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No plugins installed</p>
<p className="text-xs mt-1">
Install a plugin from git or place one in the plugins directory.
</p>
</div>
) : (
plugins.map((plugin) => (
<div
key={plugin.name}
className={`border rounded-lg p-4 transition-colors ${
plugin.enabled
? 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50'
: 'border-gray-200/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-900/25 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{plugin.displayName}</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
v{plugin.version}
</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{plugin.type}
</span>
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground mt-1">{plugin.description}</p>
)}
{plugin.author && (
<p className="text-xs text-muted-foreground mt-1">by {plugin.author}</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => togglePlugin(plugin.name, !plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? (
<Power className="w-4 h-4 text-green-500" />
) : (
<PowerOff className="w-4 h-4 text-muted-foreground" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void handleUpdate(plugin.name)}
disabled={updatingPlugin === plugin.name}
title="Pull latest from git"
>
{updatingPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleUninstall(plugin.name)}
className={confirmUninstall === plugin.name ? 'text-red-500 hover:text-red-600' : ''}
title={confirmUninstall === plugin.name ? 'Click again to confirm' : 'Uninstall plugin'}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{updateError?.name === plugin.name && (
<p className="mt-2 text-sm text-red-500">{updateError.message}</p>
)}
{confirmUninstall === plugin.name && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-600 dark:text-red-400 flex items-center justify-between">
<span>Uninstall {plugin.displayName}? This cannot be undone.</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmUninstall(null)}
>
Cancel
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
onClick={() => handleUninstall(plugin.name)}
>
Confirm
</Button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import type { Project, ProjectSession } from '../../types/app';
type PluginTabContentProps = {
pluginName: string;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
};
export default function PluginTabContent({
pluginName,
selectedProject,
selectedSession,
}: PluginTabContentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { isDarkMode } = useTheme();
const iframeSrc = `/api/plugins/${encodeURIComponent(pluginName)}/assets/index.html`;
// Send context to iframe when it loads or when context changes.
// Use '*' as targetOrigin because the sandbox (without allow-same-origin) gives the
// iframe an opaque origin that cannot be matched with a specific origin string.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const sendContext = () => {
iframe.contentWindow?.postMessage(
{
type: 'ccui:context',
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
: null,
session: selectedSession
? { id: selectedSession.id, title: selectedSession.title }
: null,
},
'*',
);
};
iframe.addEventListener('load', sendContext);
// Also send when context changes (iframe already loaded)
if (iframe.contentWindow) {
sendContext();
}
return () => {
iframe.removeEventListener('load', sendContext);
};
}, [isDarkMode, selectedProject, selectedSession]);
// Listen for messages from plugin iframe.
// We verify by event.source rather than event.origin because the sandboxed iframe
// (without allow-same-origin) has an opaque origin that shows up as "null".
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only accept messages originating from our plugin iframe
if (event.source !== iframeRef.current?.contentWindow) return;
if (!event.data || typeof event.data !== 'object') return;
const { type } = event.data;
switch (type) {
case 'ccui:request-context': {
// Plugin is requesting current context
iframeRef.current?.contentWindow?.postMessage(
{
type: 'ccui:context',
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
: null,
session: selectedSession
? { id: selectedSession.id, title: selectedSession.title }
: null,
},
'*',
);
break;
}
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [isDarkMode, selectedProject, selectedSession]);
return (
<div className="h-full w-full overflow-hidden">
<iframe
ref={iframeRef}
src={iframeSrc}
title={`Plugin: ${pluginName}`}
className="w-full h-full border-0"
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -10,6 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
@@ -176,6 +177,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
<CredentialsSettingsTab />
</div>
)}
{activeTab === 'plugins' && (
<div className="space-y-6 md:space-y-8">
<PluginSettingsTab />
</div>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { GitBranch, Key } from 'lucide-react';
import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
type MainTabConfig = {
id: SettingsMainTab;
labelKey: string;
labelKey?: string;
label?: string;
icon?: typeof GitBranch;
};
@@ -19,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -44,7 +46,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
}`}
>
{Icon && <Icon className="w-4 h-4 inline mr-2" />}
{t(tab.labelKey)}
{tab.labelKey ? t(tab.labelKey) : tab.label}
</button>
);
})}

View File

@@ -0,0 +1,128 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { authenticatedFetch } from '../utils/api';
export type Plugin = {
name: string;
displayName: string;
version: string;
description: string;
author: string;
icon: string;
type: 'iframe' | 'react';
slot: 'tab';
entry: string;
permissions: string[];
enabled: boolean;
dirName: string;
};
type PluginsContextValue = {
plugins: Plugin[];
loading: boolean;
refreshPlugins: () => Promise<void>;
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
togglePlugin: (name: string, enabled: boolean) => Promise<void>;
};
const PluginsContext = createContext<PluginsContextValue | null>(null);
export function usePlugins() {
const context = useContext(PluginsContext);
if (!context) {
throw new Error('usePlugins must be used within a PluginsProvider');
}
return context;
}
export function PluginsProvider({ children }: { children: ReactNode }) {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [loading, setLoading] = useState(true);
const refreshPlugins = useCallback(async () => {
try {
const res = await authenticatedFetch('/api/plugins');
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
}
} catch (err) {
console.error('[Plugins] Failed to fetch plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshPlugins();
}, [refreshPlugins]);
const installPlugin = useCallback(async (url: string) => {
try {
const res = await authenticatedFetch('/api/plugins/install', {
method: 'POST',
body: JSON.stringify({ url }),
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Install failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
}
}, [refreshPlugins]);
const uninstallPlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Uninstall failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
}
}, [refreshPlugins]);
const updatePlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Update failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
}
}, [refreshPlugins]);
const togglePlugin = useCallback(async (name: string, enabled: boolean) => {
try {
await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
await refreshPlugins();
} catch (err) {
console.error('[Plugins] Failed to toggle plugin:', err);
}
}, [refreshPlugins]);
return (
<PluginsContext.Provider value={{ plugins, loading, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
{children}
</PluginsContext.Provider>
);
}

View File

@@ -106,10 +106,14 @@ const isUpdateAdditive = (
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
};
const readPersistedTab = (): AppTab => {
try {
const stored = localStorage.getItem('activeTab');
if (stored && VALID_TABS.has(stored)) {
if (stored && isValidTab(stored)) {
return stored as AppTab;
}
} catch {

View File

@@ -1,6 +1,6 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession {
id: string;