mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-03 11:05:35 +08:00
feat: new plugin system
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Terminal,
|
||||
Folder,
|
||||
GitBranch,
|
||||
ClipboardCheck,
|
||||
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';
|
||||
import PluginIcon from '../../../plugins/PluginIcon';
|
||||
|
||||
type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
@@ -10,20 +19,32 @@ type MainContentTabSwitcherProps = {
|
||||
shouldShowTasksTab: boolean;
|
||||
};
|
||||
|
||||
type TabDefinition = {
|
||||
type BuiltInTab = {
|
||||
kind: 'builtin';
|
||||
id: AppTab;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const BASE_TABS: TabDefinition[] = [
|
||||
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
||||
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
type PluginTab = {
|
||||
kind: 'plugin';
|
||||
id: AppTab;
|
||||
label: string;
|
||||
pluginName: string;
|
||||
iconFile: string;
|
||||
};
|
||||
|
||||
type TabDefinition = BuiltInTab | PluginTab;
|
||||
|
||||
const BASE_TABS: BuiltInTab[] = [
|
||||
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
||||
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
];
|
||||
|
||||
const TASKS_TAB: TabDefinition = {
|
||||
const TASKS_TAB: BuiltInTab = {
|
||||
kind: 'builtin',
|
||||
id: 'tasks',
|
||||
labelKey: 'tabs.tasks',
|
||||
icon: ClipboardCheck,
|
||||
@@ -35,17 +56,30 @@ export default function MainContentTabSwitcher({
|
||||
shouldShowTasksTab,
|
||||
}: MainContentTabSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
|
||||
const pluginTabs: PluginTab[] = plugins
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => ({
|
||||
kind: 'plugin',
|
||||
id: `plugin:${p.name}` as AppTab,
|
||||
label: p.displayName,
|
||||
pluginName: p.name,
|
||||
iconFile: p.icon,
|
||||
}));
|
||||
|
||||
const tabs: TabDefinition[] = [...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.kind === 'builtin' ? 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 ${
|
||||
@@ -54,8 +88,16 @@ export default function MainContentTabSwitcher({
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
|
||||
{tab.kind === 'builtin' ? (
|
||||
<tab.icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
) : (
|
||||
<PluginIcon
|
||||
pluginName={tab.pluginName}
|
||||
iconFile={tab.iconFile}
|
||||
className="w-3.5 h-3.5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden lg:inline">{displayLabel}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
347
src/components/plugins/PluginSettingsTab.tsx
Normal file
347
src/components/plugins/PluginSettingsTab.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useState } from 'react';
|
||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight } from 'lucide-react';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import PluginIcon from './PluginIcon';
|
||||
import type { Plugin } from '../../contexts/PluginsContext';
|
||||
|
||||
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
relative w-9 h-5 rounded-full transition-colors duration-200
|
||||
bg-muted peer-checked:bg-emerald-500
|
||||
after:absolute after:content-[''] after:top-[2px] after:left-[2px]
|
||||
after:w-4 after:h-4 after:rounded-full after:bg-white after:shadow-sm
|
||||
after:transition-transform after:duration-200
|
||||
peer-checked:after:translate-x-4
|
||||
`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Server Dot ────────────────────────────────────────────────────────── */
|
||||
function ServerDot({ running }: { running: boolean }) {
|
||||
if (!running) return null;
|
||||
return (
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400 tracking-wide uppercase">
|
||||
running
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
|
||||
type PluginCardProps = {
|
||||
plugin: Plugin;
|
||||
index: number;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onUninstall: () => void;
|
||||
updating: boolean;
|
||||
confirmingUninstall: boolean;
|
||||
onCancelUninstall: () => void;
|
||||
updateError: string | null;
|
||||
};
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
index,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
updating,
|
||||
confirmingUninstall,
|
||||
onCancelUninstall,
|
||||
updateError,
|
||||
}: PluginCardProps) {
|
||||
const accentColor = plugin.enabled
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-muted-foreground/20';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex rounded-md border border-border bg-card overflow-hidden transition-opacity duration-200"
|
||||
style={{
|
||||
opacity: plugin.enabled ? 1 : 0.65,
|
||||
animationDelay: `${index * 40}ms`,
|
||||
}}
|
||||
>
|
||||
{/* Left accent bar */}
|
||||
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
|
||||
|
||||
<div className="flex-1 p-3.5 min-w-0">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex-shrink-0 w-4 h-4 text-foreground/80">
|
||||
<PluginIcon
|
||||
pluginName={plugin.name}
|
||||
iconFile={plugin.icon}
|
||||
className="w-4 h-4 [&>svg]:w-full [&>svg]:h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground leading-none">
|
||||
{plugin.displayName}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{plugin.type}
|
||||
</span>
|
||||
<ServerDot running={!!plugin.serverRunning} />
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-snug">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
{plugin.author && (
|
||||
<p className="text-[10px] font-mono text-muted-foreground/60 mt-0.5">
|
||||
{plugin.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
disabled={updating}
|
||||
title="Pull latest from git"
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||||
>
|
||||
{updating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
confirmingUninstall
|
||||
? 'text-red-500 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||
: 'text-muted-foreground hover:text-red-500 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm uninstall banner */}
|
||||
{confirmingUninstall && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 px-3 py-2 rounded bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800/50">
|
||||
<span className="text-xs text-red-600 dark:text-red-400">
|
||||
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone.
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onCancelUninstall}
|
||||
className="text-xs px-2.5 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="text-xs px-2.5 py-1 rounded border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 font-medium transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update error */}
|
||||
{updateError && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-red-500">
|
||||
<ServerCrash className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{updateError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ───────────────────────────────────────────────────────── */
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="font-mono text-muted-foreground/30 text-xs leading-relaxed mb-4 select-none">
|
||||
<div>~/.claude-code-ui/plugins/</div>
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span>(empty)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Install from git or drop a folder in the plugins directory
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Component ────────────────────────────────────────────────────── */
|
||||
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 [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleUpdate = async (name: string) => {
|
||||
setUpdatingPlugin(name);
|
||||
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
||||
const result = await updatePlugin(name);
|
||||
if (!result.success) {
|
||||
setUpdateErrors((prev) => ({ ...prev, [name]: 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-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground/60 mb-1">
|
||||
Plugins
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Extend the interface with custom tabs. Drop a folder in{' '}
|
||||
<code className="font-mono text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
~/.claude-code-ui/plugins/
|
||||
</code>{' '}
|
||||
or install from git.
|
||||
</p>
|
||||
</div>
|
||||
{!loading && plugins.length > 0 && (
|
||||
<span className="font-mono text-xs text-muted-foreground/50 tabular-nums">
|
||||
{plugins.filter((p) => p.enabled).length}/{plugins.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install from Git */}
|
||||
<div className="rounded-md border border-border bg-card p-3.5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-3.5 h-3.5 text-muted-foreground/60" />
|
||||
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
Install from git
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0 rounded-md border border-border bg-background focus-within:ring-1 focus-within:ring-ring overflow-hidden">
|
||||
<span className="flex-shrink-0 pl-3 pr-1.5 font-mono text-xs text-muted-foreground/50 select-none">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={gitUrl}
|
||||
onChange={(e) => {
|
||||
setGitUrl(e.target.value);
|
||||
setInstallError(null);
|
||||
}}
|
||||
placeholder="git clone https://github.com/user/my-plugin"
|
||||
className="flex-1 px-1.5 py-2.5 text-xs font-mono bg-transparent text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleInstall();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !gitUrl.trim()}
|
||||
className="flex-shrink-0 px-4 py-2.5 text-xs font-medium bg-foreground text-background hover:opacity-90 disabled:opacity-30 transition-opacity border-l border-border"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
'Install'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{installError && (
|
||||
<p className="mt-2 text-xs font-mono text-red-500">{installError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plugin List */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 justify-center py-10 text-xs text-muted-foreground font-mono">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
scanning plugins…
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
plugins.map((plugin, index) => (
|
||||
<PluginCard
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugin === plugin.name}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/plugins/PluginTabContent.tsx
Normal file
126
src/components/plugins/PluginTabContent.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
|
||||
type PluginTabContentProps = {
|
||||
pluginName: string;
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
};
|
||||
|
||||
type PluginContext = {
|
||||
theme: 'dark' | 'light';
|
||||
project: { name: string; path: string } | null;
|
||||
session: { id: string; title: string } | null;
|
||||
};
|
||||
|
||||
function buildContext(
|
||||
isDarkMode: boolean,
|
||||
selectedProject: Project | null,
|
||||
selectedSession: ProjectSession | null,
|
||||
): PluginContext {
|
||||
return {
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
project: selectedProject
|
||||
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
|
||||
: null,
|
||||
session: selectedSession
|
||||
? { id: selectedSession.id, title: selectedSession.title }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PluginTabContent({
|
||||
pluginName,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
}: PluginTabContentProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isDarkMode } = useTheme();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
// Stable refs so effects don't need context values in their dep arrays
|
||||
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
||||
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const moduleRef = useRef<any>(null);
|
||||
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
|
||||
// Keep contextRef current and notify the mounted plugin on every context change
|
||||
useEffect(() => {
|
||||
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
|
||||
contextRef.current = ctx;
|
||||
|
||||
for (const cb of contextCallbacksRef.current) {
|
||||
try { cb(ctx); } catch { /* plugin error — ignore */ }
|
||||
}
|
||||
}, [isDarkMode, selectedProject, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let active = true;
|
||||
const container = containerRef.current;
|
||||
const entryFile = plugin?.entry ?? 'index.js';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
|
||||
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
|
||||
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
|
||||
const res = await authenticatedFetch(assetUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
|
||||
const jsText = await res.text();
|
||||
const blob = new Blob([jsText], { type: 'application/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
// @vite-ignore
|
||||
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
|
||||
if (!active || !containerRef.current) return;
|
||||
|
||||
moduleRef.current = mod;
|
||||
|
||||
const api = {
|
||||
get context(): PluginContext { return contextRef.current; },
|
||||
|
||||
onContextChange(cb: (ctx: PluginContext) => void): () => void {
|
||||
contextCallbacksRef.current.add(cb);
|
||||
return () => contextCallbacksRef.current.delete(cb);
|
||||
},
|
||||
|
||||
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const cleanPath = String(path).replace(/^\//, '');
|
||||
const res = await authenticatedFetch(
|
||||
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
|
||||
{
|
||||
method: method || 'GET',
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`RPC error ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
await mod.mount?.(container, api);
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
|
||||
contextCallbacksRef.current.clear();
|
||||
moduleRef.current = null;
|
||||
};
|
||||
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
130
src/contexts/PluginsContext.tsx
Normal file
130
src/contexts/PluginsContext.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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' | 'module';
|
||||
slot: 'tab';
|
||||
entry: string;
|
||||
server: string | null;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
serverRunning: 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user