403 lines · 14.0 KB · TypeScript
RawDownload
"use client"; import { useCallback, useMemo, useRef, useState } from "react";import { Schema } from "effect";import {  PluginRuntimeResponseSchema,  type PluginRuntimeView,} from "@local-studio/agent-runtime/plugin-runtime-contract";import { ApiErrorResponseSchema } from "@local-studio/agent-runtime/api-contract";import { Alert, Button, SearchInput, StatusPill, UiModal, UiModalHeader } from "@/ui";import { Eye, X } from "@/ui/icon-registry";import { useMountSubscription } from "@/hooks/use-mount-subscription";import {  SettingsButton,  SettingsGroup,  SettingsRow,  SettingsValue,  type StatusTone,} from "@/features/settings/settings-ui";import { GoogleAccountModal } from "./google-account-modal";import { ChatterboxVoiceModal } from "./chatterbox-voice-modal";import { speechStatusLabel, speechStatusTone } from "./chatterbox-voice-model";import { useSpeechStore, type SpeechSnapshot } from "./chatterbox-voice-store"; type PluginStatus = { label: string; tone: StatusTone }; function responseError(body: unknown, fallback: string): string {  try {    return Schema.decodeUnknownSync(ApiErrorResponseSchema)(body).error;  } catch {    return fallback;  }} async function pluginResponse(response: Response, fallback: string) {  const body: unknown = await response.json().catch(() => null);  if (!response.ok) throw new Error(responseError(body, fallback));  return Schema.decodeUnknownSync(PluginRuntimeResponseSchema)(body);} function capabilitySummary(plugin: PluginRuntimeView): string {  if (plugin.hostCapability?.capability === "speech") {    return `local speech · voice cloning · v${plugin.version}`;  }  return [    plugin.provides.skills ? "skills" : null,    plugin.provides.mcpServers || plugin.account      ? `${plugin.tools.serverCount} ${plugin.account ? "remote " : ""}MCP ${plugin.tools.serverCount === 1 ? "server" : "servers"}`      : null,    plugin.provides.apps ? "account app" : null,    `v${plugin.version}`,  ]    .filter((value): value is string => Boolean(value))    .join(" · ");} function pluginStatus(plugin: PluginRuntimeView, speech: SpeechSnapshot): PluginStatus {  if (plugin.hostCapability?.capability === "speech") {    if (!speech.available && !speech.loading) return { label: "Unavailable", tone: "danger" };    if (speech.status) {      return {        label: speechStatusLabel(speech.status),        tone: speechStatusTone(speech.status),      };    }    if (speech.loading) return { label: "Checking", tone: "default" };    return { label: speech.error ? "Unavailable" : "Configure", tone: "warning" };  }  if (plugin.account && !plugin.account.configured) return { label: "Setup", tone: "warning" };  if (plugin.account && !plugin.account.connected) return { label: "Sign in", tone: "warning" };  if (plugin.tools.state === "enabled") {    return {      label: `Observe · ${plugin.tools.allowedToolCount} ${plugin.tools.allowedToolCount === 1 ? "tool" : "tools"}`,      tone: "good",    };  }  if (plugin.tools.state === "available") return { label: "Available", tone: "info" };  if (plugin.tools.state === "disabled") return { label: "Off", tone: "default" };  if (plugin.tools.state === "invalid") return { label: "Invalid manifest", tone: "danger" };  if (plugin.tools.state === "configuration_required" || plugin.provides.apps) {    return { label: "Adapter needed", tone: "warning" };  }  return { label: "Skills", tone: "default" };} function activationAction(plugin: PluginRuntimeView): "account" | "connect" | "disconnect" | null {  if (plugin.hostCapability) return null;  if (plugin.account && !plugin.account.connected) return "account";  if (plugin.account) {    return plugin.tools.state === "available" || plugin.tools.state === "disabled"      ? "connect"      : null;  }  if (plugin.tools.state === "enabled") return "disconnect";  if (plugin.tools.state === "available" || plugin.tools.state === "disabled") return "connect";  return null;} function PluginRowsSkeleton() {  return (    <>      {[0, 1, 2].map((index) => (        <div key={index} className="grid animate-pulse gap-3 px-4 py-3 md:grid-cols-2">          <div className="space-y-2">            <div className="h-3 w-32 rounded bg-(--ui-hover)" />            <div className="h-2.5 w-56 max-w-full rounded bg-(--ui-hover)/70" />          </div>          <div className="flex items-center justify-end gap-3">            <div className="h-2.5 w-36 rounded bg-(--ui-hover)/70" />            <div className="h-5 w-20 rounded-full bg-(--ui-hover)" />          </div>        </div>      ))}    </>  );} type PluginRowAction = ReturnType<typeof activationAction>; function pluginActionLabel(plugin: PluginRuntimeView, action: PluginRowAction): string {  if (action === "account") return plugin.account?.configured ? "Sign in" : "Set up";  if (action === "connect") return "Connect";  return "Disconnect";} function PluginRowActions({  plugin,  action,  busy,  hostActionLabel,  onConnect,  onDisconnect,  onAccount,  onHostCapability,}: {  plugin: PluginRuntimeView;  action: PluginRowAction;  busy: boolean;  hostActionLabel: string;  onConnect: () => void;  onDisconnect: () => void;  onAccount: () => void;  onHostCapability: () => void;}) {  const actionLabel = action ? pluginActionLabel(plugin, action) : "";  const handleAction =    action === "account" ? onAccount : action === "connect" ? onConnect : onDisconnect;  return (    <>      {plugin.hostCapability ? (        <SettingsButton          onClick={onHostCapability}          disabled={busy}          aria-label={`${hostActionLabel} ${plugin.displayName}`}        >          {hostActionLabel}        </SettingsButton>      ) : null}      {plugin.account?.connected ? (        <SettingsButton          onClick={onAccount}          disabled={busy}          aria-label={`Manage ${plugin.displayName}`}        >          Manage        </SettingsButton>      ) : null}      {action ? (        <SettingsButton          onClick={handleAction}          disabled={busy}          aria-label={`${actionLabel} ${plugin.displayName}`}        >          {busy ? "Working" : actionLabel}        </SettingsButton>      ) : null}    </>  );} function PluginRow({  plugin,  speech,  busy,  onConnect,  onDisconnect,  onAccount,  onHostCapability,}: {  plugin: PluginRuntimeView;  speech: SpeechSnapshot;  busy: boolean;  onConnect: () => void;  onDisconnect: () => void;  onAccount: () => void;  onHostCapability: () => void;}) {  const status = pluginStatus(plugin, speech);  const action = activationAction(plugin);  const hostActionLabel = speech.status?.install.phase === "ready" ? "Manage" : "Configure";  return (    <SettingsRow      label={plugin.displayName}      description={plugin.description || plugin.category}      value={<SettingsValue dim>{capabilitySummary(plugin)}</SettingsValue>}      status={<StatusPill tone={status.tone}>{status.label}</StatusPill>}      actions={        action || plugin.account?.connected || plugin.hostCapability ? (          <PluginRowActions            plugin={plugin}            action={action}            busy={busy}            hostActionLabel={hostActionLabel}            onConnect={onConnect}            onDisconnect={onDisconnect}            onAccount={onAccount}            onHostCapability={onHostCapability}          />        ) : undefined      }    >      {plugin.tools.reason ? (        <div className="text-[length:var(--fs-sm)] text-(--ui-muted)">{plugin.tools.reason}</div>      ) : null}      {plugin.account?.email ? (        <div className="text-[length:var(--fs-sm)] text-(--ui-muted)">{plugin.account.email}</div>      ) : null}    </SettingsRow>  );} export function PluginsSection() {  const speech = useSpeechStore();  const [plugins, setPlugins] = useState<readonly PluginRuntimeView[]>([]);  const [loaded, setLoaded] = useState(false);  const [error, setError] = useState("");  const [query, setQuery] = useState("");  const [busyId, setBusyId] = useState<string | null>(null);  const [pending, setPending] = useState<PluginRuntimeView | null>(null);  const [accountPlugin, setAccountPlugin] = useState<PluginRuntimeView | null>(null);  const [speechPlugin, setSpeechPlugin] = useState<PluginRuntimeView | null>(null);  const requestGeneration = useRef(0);   const loadPlugins = useCallback(() => {    const generation = ++requestGeneration.current;    return fetch("/api/agent/plugins", { cache: "no-store" })      .then(async (response) => {        const payload = await pluginResponse(response, "Plugin discovery failed");        if (generation !== requestGeneration.current) return;        setPlugins(payload.plugins);        setError("");      })      .catch((loadError: unknown) => {        if (generation !== requestGeneration.current) return;        setError(loadError instanceof Error ? loadError.message : "Plugin discovery failed");      })      .finally(() => {        if (generation === requestGeneration.current) setLoaded(true);      });  }, []);   useMountSubscription(() => {    void loadPlugins();  }, [loadPlugins]);   const handleAccountChanged = useCallback(() => {    void loadPlugins();  }, [loadPlugins]);   const visiblePlugins = useMemo(() => {    const normalized = query.trim().toLowerCase();    if (!normalized) return plugins;    return plugins.filter((plugin) =>      `${plugin.displayName} ${plugin.description} ${plugin.category} ${capabilitySummary(plugin)}`        .toLowerCase()        .includes(normalized),    );  }, [plugins, query]);   const setEnabled = async (plugin: PluginRuntimeView, enabled: boolean) => {    const generation = ++requestGeneration.current;    setBusyId(plugin.id);    setError("");    try {      const response = await fetch(`/api/agent/plugins/${encodeURIComponent(plugin.id)}`, {        method: "POST",        headers: { "Content-Type": "application/json" },        body: JSON.stringify({ enabled }),      });      const payload = await pluginResponse(response, "Plugin activation failed");      if (generation !== requestGeneration.current) return;      setPlugins(payload.plugins);      setPending(null);    } catch (activationError) {      if (generation !== requestGeneration.current) return;      setError(        activationError instanceof Error ? activationError.message : "Plugin activation failed",      );    } finally {      setBusyId((current) => (current === plugin.id ? null : current));    }  };   return (    <>      <div className="mb-4 space-y-3 px-4">        <p className="max-w-3xl text-[length:var(--fs-sm)] leading-relaxed text-(--ui-muted)">          Codex-compatible bundles discovered locally. Skills are available immediately; executable          tools require explicit permission.        </p>        <SearchInput          value={query}          onChange={setQuery}          placeholder="Search plugins"          className="max-w-md"        />      </div>      {error ? (        <div className="mb-4 px-4">          <Alert variant="error">{error}</Alert>        </div>      ) : null}      <SettingsGroup        title="Plugins"        actions={          <StatusPill tone={error ? "warning" : loaded ? "good" : "default"}>            {loaded ? `${visiblePlugins.length} of ${plugins.length}` : "discovering"}          </StatusPill>        }      >        {!loaded ? (          <PluginRowsSkeleton />        ) : visiblePlugins.length ? (          visiblePlugins.map((plugin) => (            <PluginRow              key={plugin.id}              plugin={plugin}              speech={speech}              busy={busyId === plugin.id}              onConnect={() => setPending(plugin)}              onDisconnect={() => void setEnabled(plugin, false)}              onAccount={() => setAccountPlugin(plugin)}              onHostCapability={() => setSpeechPlugin(plugin)}            />          ))        ) : (          <div className="px-4 py-8 text-center text-[length:var(--fs-md)] text-(--ui-muted)">            {plugins.length ? `No plugins match “${query}”.` : "No plugin manifests found."}          </div>        )}      </SettingsGroup>      <UiModal        isOpen={pending !== null}        onClose={() => !busyId && setPending(null)}        maxWidth="max-w-md"      >        <UiModalHeader          title={`Connect ${pending?.displayName ?? "plugin"}?`}          icon={            <span className="flex h-8 w-8 items-center justify-center rounded-lg border border-(--ui-info)/30 bg-(--ui-info)/10">              <Eye className="h-4 w-4 text-(--ui-info)" />            </span>          }          onClose={() => !busyId && setPending(null)}          closeIcon={<X className="h-4 w-4" />}        />        <div className="space-y-5 px-6 py-5">          <Alert variant="info">            Observe mode starts this plugin locally and exposes only tools it declares read-only.            Desktop actions stay blocked until Local Studio has an action-time approval prompt.          </Alert>          <p className="text-sm leading-6 text-(--ui-muted)">            The bundle remains in its installed location. Disconnecting stops exposing its tools to            Workbench sessions.          </p>          <div className="flex justify-end gap-2">            <Button variant="secondary" onClick={() => setPending(null)} disabled={Boolean(busyId)}>              Cancel            </Button>            <Button              onClick={() => pending && void setEnabled(pending, true)}              disabled={!pending || Boolean(busyId)}              loading={Boolean(busyId)}            >              Connect in observe mode            </Button>          </div>        </div>      </UiModal>      {accountPlugin?.account?.provider === "google" ? (        <GoogleAccountModal          accountId={accountPlugin.account.id}          displayName={accountPlugin.displayName}          onClose={() => setAccountPlugin(null)}          onChanged={handleAccountChanged}        />      ) : null}      {speechPlugin?.hostCapability?.capability === "speech" ? (        <ChatterboxVoiceModal key={speech.controllerKey} onClose={() => setSpeechPlugin(null)} />      ) : null}    </>  );}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t