agent-ecosystem/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
Artem Rootman da58917032
perf: offload heavy I/O to worker thread, reduce renderer re-renders
Main process — worker thread for team data:
- New team-data-worker thread handles getTeamData and findLogsForTask,
  isolating heavy file I/O (scanning 300+ subagent JSONL files) from
  Electron's main event loop. getTeamData dropped from ~2000ms on the
  main thread to ~110ms via the worker.
- Worker-side dedup and 10s result cache for findLogsForTask prevents
  redundant scans when the same task is queried multiple times.
- Discovery cache TTL raised from 5s to 30s — avoids re-scanning the
  entire project directory on every call.
- Message cap at 200 in TeamDataService to keep IPC payloads under 1MB
  (was sending 2200+ messages / ~3MB, stalling Chromium IPC serialization).
- IPC handlers fall back to main-thread execution if the worker is
  unavailable (graceful degradation).

Renderer — useShallow and memoization (55 files):
- Added useShallow to store selectors across 55 renderer files. Batched
  individual useStore() calls (e.g. 17 calls in ExtensionStoreView,
  10 in ConnectionSection) into single useShallow selectors, cutting
  unnecessary re-render checks on every store update.
- MemberLogsTab: three 5-second polling intervals now pause when the
  parent tab is hidden (display:none). Previously 5 hidden tabs × 3
  intervals = 15 polling timers firing continuously.
- KanbanColumn wrapped in React.memo to skip re-renders when props
  haven't changed.
- MemberList: memoized activeMembers/removedMembers/colorMap; replaced
  O(n×m) per-member task scan with a pre-computed reviewer map.
- Bounded timer Maps in store initialization to prevent unbounded growth
  of debounce/throttle tracking maps during long sessions.
2026-04-05 16:21:05 +00:00

220 lines
7.5 KiB
TypeScript

/**
* PluginDetailDialog — full detail view for a single plugin with install controls.
*/
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import {
getCapabilityLabel,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2, Mail } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import { SourceBadge } from '../common/SourceBadge';
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
interface PluginDetailDialogProps {
plugin: EnrichedPlugin | null;
open: boolean;
onClose: () => void;
}
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
];
export const PluginDetailDialog = ({
plugin,
open,
onClose,
}: PluginDetailDialogProps): React.JSX.Element => {
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin,
}))
);
const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
);
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user');
useEffect(() => {
if (plugin && open) {
fetchPluginReadme(plugin.pluginId);
}
}, [plugin, open, fetchPluginReadme]);
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<SourceBadge source={plugin.source} />
</div>
</div>
</DialogHeader>
{/* Metadata grid */}
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<span className="text-text-muted">Author</span>
<p className="text-text">{plugin.author?.name ?? 'Unknown'}</p>
</div>
<div>
<span className="text-text-muted">Category</span>
<p className="capitalize text-text">{category}</p>
</div>
<div>
<span className="text-text-muted">Source</span>
<p className="capitalize text-text">{plugin.source}</p>
</div>
{plugin.version && (
<div>
<span className="text-text-muted">Version</span>
<p className="text-text">{plugin.version}</p>
</div>
)}
<div>
<span className="text-text-muted">Capabilities</span>
<div className="mt-0.5 flex flex-wrap gap-1">
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-purple-500/30 bg-purple-500/10 text-purple-400"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
</div>
<div>
<span className="text-text-muted">Installs</span>
<div className="mt-0.5">
<InstallCountBadge count={plugin.installCount} />
</div>
</div>
</div>
{/* Install controls */}
<div className="flex items-center gap-3 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex flex-1 items-center gap-2">
<Label className="text-xs text-text-muted">Scope:</Label>
<Select value={scope} onValueChange={(v) => setScope(v as InstallScope)}>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
size="default"
errorMessage={installError}
/>
</div>
{/* Links */}
<div className="flex items-center gap-4">
{plugin.homepage && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(plugin.homepage!)}
>
<ExternalLink className="mr-1 size-3.5" />
Homepage
</Button>
)}
{plugin.author?.email && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
>
<Mail className="mr-1 size-3.5" />
Contact
</Button>
)}
</div>
{/* README */}
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
{isReadmeLoading && (
<div className="flex items-center gap-2 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
Loading README...
</div>
)}
{!isReadmeLoading && readme && (
<MarkdownViewer content={readme} bare maxHeight="max-h-none" />
)}
{!isReadmeLoading && !readme && (
<p className="text-sm text-text-muted">No README available.</p>
)}
</div>
</DialogContent>
</Dialog>
);
};