Home

mweinbach / agent-coworker

publicmweinbach/agent-coworker
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
mweinbach/agent-coworker/apps/desktop/src/ui/settings/pages/ToolAccessPage.tsx
Raw
1import { PackageIcon, SearchIcon } from "lucide-react";2import { type ReactNode, useEffect, useMemo, useState } from "react";3 4import { useAppStore } from "../../../app/store";5import { resolveManagementWorkspaceId } from "../../../app/workspaceDisplayTargets";6import
{ Button }
from
"../../../components/ui/button"
;
7import { Input } from "../../../components/ui/input";
8import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../components/ui/tabs";
9import { InlineErrorBoundary } from "../../CrashReportingErrorBoundary";
10import { SettingsEmptyState, SettingsStatusPill } from "../SettingsPrimitives";
11import { PluginsSection } from "../toolAccess/PluginsSection";
12import { SkillsSection } from "../toolAccess/SkillsSection";
13import { McpServersPage } from "./McpServersPage";
14import { OpenAiNativeConnectorsPage } from "./OpenAiNativeConnectorsPage";
15import { ProvidersPage } from "./ProvidersPage";
16import { SearchSettingsCard } from "./WorkspacesPage";
17 
18const TOOL_ACCESS_TAB_IDS = ["plugins", "skills", "connectors", "apps", "search"] as const;
19 
20type ToolAccessTabId = (typeof TOOL_ACCESS_TAB_IDS)[number];
21 
22function isToolAccessTabId(value: string): value is ToolAccessTabId {
23 return (TOOL_ACCESS_TAB_IDS as readonly string[]).includes(value);
24}
25 
26/** Tabs where the shell search input is hidden because the tab owns its own search UI (or has none). */
27const TAB_SEARCH_PLACEHOLDER: Record<ToolAccessTabId, string | null> = {
28 plugins: "Search plugins…",
29 skills: "Search skills…",
30 connectors: "Search connectors…",
31 apps: null,
32 search: null,
33};
34 
35function MutationErrorBanner({ message, onDismiss }: { message: string; onDismiss: () => void }) {
36 return (
37 <div className="flex flex-wrap items-center gap-3 rounded-lg bg-destructive/5 px-4 py-3">
38 <SettingsStatusPill tone="danger">Install failed</SettingsStatusPill>
39 <span className="min-w-0 flex-1 truncate text-xs text-destructive">{message}</span>
40 <Button size="sm" variant="outline" onClick={onDismiss}>
41 Dismiss
42 </Button>
43 </div>
44 );
45}
46 
47function CatalogEmptyState() {
48 return (
49 <SettingsEmptyState
50 icon={<PackageIcon />}
51 title="No workspaces yet"
52 description="Add a project or start a chat first to load plugin, skill, and MCP server catalogs."
53 />
54 );
55}
56 
57export function ToolAccessTabs() {
58 const workspaceId = useToolAccessCatalogWorkspaceId();
59 const openAiNativeConnectorsAvailable = useAppStore(
60 (s) => s.desktopFeatureFlags.openAiNativeConnectors === true,
61 );
62 const workspaces = useAppStore((s) => s.workspaces);
63 const runtime = useAppStore((s) =>
64 workspaceId ? s.workspaceRuntimeById[workspaceId] : undefined,
65 );
66 const providerStatusByName = useAppStore((s) => s.providerStatusByName);
67 const updateWorkspaceDefaults = useAppStore((s) => s.updateWorkspaceDefaults);
68 const refreshPluginsCatalog = useAppStore((s) => s.refreshPluginsCatalog);
69 const refreshSkillsCatalog = useAppStore((s) => s.refreshSkillsCatalog);
70 const requestWorkspaceMcpServers = useAppStore((s) => s.requestWorkspaceMcpServers);
71 const dismissPluginMutationError = useAppStore((s) => s.dismissPluginMutationError);
72 const dismissSkillMutationError = useAppStore((s) => s.dismissSkillMutationError);
73 
74 const [activeTab, setActiveTab] = useState<ToolAccessTabId>("plugins");
75 const [searchQuery, setSearchQuery] = useState("");
76 
77 // Load every catalog up front so tab counts are populated regardless of
78 // which tab is active. Sections don't refetch on mount; the Connectors tab
79 // re-requests through its own anchor, which is an idempotent read.
80 useEffect(() => {
81 if (!workspaceId) return;
82 void refreshPluginsCatalog();
83 void refreshSkillsCatalog(workspaceId);
84 void requestWorkspaceMcpServers(workspaceId);
85 }, [workspaceId, refreshPluginsCatalog, refreshSkillsCatalog, requestWorkspaceMcpServers]);
86 
87 const workspace = useMemo(
88 () => (workspaceId ? (workspaces.find((entry) => entry.id === workspaceId) ?? null) : null),
89 [workspaces, workspaceId],
90 );
91 
92 const pluginsCatalog = runtime?.pluginsCatalog ?? null;
93 const skillsCatalog = runtime?.skillsCatalog ?? null;
94 const mcpServerCount = runtime?.mcpServers.length ?? 0;
95 const appsCount = runtime?.openAiNativeConnectors.length ?? 0;
96 const pluginMutationError = runtime?.pluginMutationError ?? null;
97 const skillMutationError = runtime?.skillMutationError ?? null;
98 
99 // `null` means "not loaded yet" and hides the count in the tab label. The
100 // connectors/apps lists have no explicit loaded flag, so an empty list stays
101 // countless instead of flashing a misleading zero.
102 const tabCounts: Record<ToolAccessTabId, number | null> = {
103 plugins: pluginsCatalog ? pluginsCatalog.plugins.length : null,
104 // Plugin-owned skill installations are managed from the plugin detail
105 // dialog, so the Skills tab only counts standalone installations.
106 skills: skillsCatalog
107 ? skillsCatalog.installations.filter((installation) => !installation.plugin).length
108 : null,
109 connectors: mcpServerCount > 0 ? mcpServerCount : null,
110 apps: appsCount > 0 ? appsCount : null,
111 search: null,
112 };
113 
114 const tabs: Array<{ id: ToolAccessTabId; label: string }> = [
115 { id: "plugins", label: "Plugins" },
116 { id: "skills", label: "Skills" },
117 { id: "connectors", label: "Connectors" },
118 ...(openAiNativeConnectorsAvailable ? [{ id: "apps" as const, label: "Apps" }] : []),
119 { id: "search", label: "Search" },
120 ];
121 
122 const effectiveTab: ToolAccessTabId = tabs.some((tab) => tab.id === activeTab)
123 ? activeTab
124 : "plugins";
125 const searchPlaceholder = TAB_SEARCH_PLACEHOLDER[effectiveTab];
126 
127 // Only the active entry mounts (see the single TabsContent below), so
128 // building the descriptors for every tab on each render stays cheap.
129 const tabContent: Record<ToolAccessTabId, ReactNode> = {
130 plugins: workspaceId ? (
131 <PluginsSection workspaceId={workspaceId} filterQuery={searchQuery} />
132 ) : (
133 <CatalogEmptyState />
134 ),
135 skills: workspaceId ? (
136 <SkillsSection workspaceId={workspaceId} filterQuery={searchQuery} />
137 ) : (
138 <CatalogEmptyState />
139 ),
140 connectors: <McpServersPage filterQuery={searchQuery} />,
141 apps: <OpenAiNativeConnectorsPage />,
142 search: (
143 <div className="flex flex-col gap-5">
144 {workspace ? (
145 <SearchSettingsCard
146 workspace={workspace}
147 updateWorkspaceDefaults={updateWorkspaceDefaults}
148 providerStatusByName={providerStatusByName}
149 />
150 ) : null}
151 <ProvidersPage surface="tools" />
152 </div>
153 ),
154 };
155 
156 return (
157 <InlineErrorBoundary label="The tool access settings couldn't be loaded.">
158 <Tabs
159 value={effectiveTab}
160 onValueChange={(value) => {
161 if (isToolAccessTabId(value)) setActiveTab(value);
162 }}
163 className="gap-4"
164 >
165 <div className="flex flex-wrap items-center justify-between gap-3">
166 <TabsList>
167 {tabs.map((tab) => {
168 const count = tabCounts[tab.id];
169 return (
170 <TabsTrigger key={tab.id} value={tab.id}>
171 {tab.label}
172 {count !== null ? (
173 <span className="text-xs font-normal tabular-nums text-muted-foreground">
174 {count}
175 </span>
176 ) : null}
177 </TabsTrigger>
178 );
179 })}
180 </TabsList>
181 {searchPlaceholder ? (
182 <div className="relative">
183 <SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
184 <Input
185 value={searchQuery}
186 onChange={(event) => setSearchQuery(event.target.value)}
187 placeholder={searchPlaceholder}
188 aria-label={searchPlaceholder}
189 className="h-8 w-56 pl-8"
190 />
191 </div>
192 ) : null}
193 </div>
194 
195 {workspaceId && pluginMutationError ? (
196 <MutationErrorBanner
197 message={pluginMutationError}
198 onDismiss={() => dismissPluginMutationError(workspaceId)}
199 />
200 ) : null}
201 {workspaceId && skillMutationError ? (
202 <MutationErrorBanner
203 message={skillMutationError}
204 onDismiss={() => dismissSkillMutationError(workspaceId)}
205 />
206 ) : null}
207 
208 {/* A single panel keyed to the active tab keeps inactive sections
209 fully unmounted without relying on Radix presence transitions. */}
210 <TabsContent value={effectiveTab}>{tabContent[effectiveTab]}</TabsContent>
211 </Tabs>
212 </InlineErrorBoundary>
213 );
214}
215 
216export function useToolAccessCatalogWorkspaceId(): string | null {
217 const workspaces = useAppStore((s) => s.workspaces);
218 const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId);
219 return useMemo(
220 () => resolveManagementWorkspaceId(workspaces, selectedWorkspaceId),
221 [workspaces, selectedWorkspaceId],
222 );
223}
224