@opengeni/react 5.0.5-canary.0 → 5.0.5-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/{chunk-SCZ6SUBW.js → chunk-H63SQTGI.js} +1258 -1837
  2. package/dist/chunk-H63SQTGI.js.map +1 -0
  3. package/dist/chunk-HQV2I5HV.js +124 -0
  4. package/dist/chunk-HQV2I5HV.js.map +1 -0
  5. package/dist/chunk-M2HNWZYR.js +835 -0
  6. package/dist/chunk-M2HNWZYR.js.map +1 -0
  7. package/dist/{chunk-Z7WBDB4E.js → chunk-V3WX3TQG.js} +283 -391
  8. package/dist/chunk-V3WX3TQG.js.map +1 -0
  9. package/dist/components/message-timeline.d.ts +3 -1
  10. package/dist/composer.js +2 -1
  11. package/dist/connect-chooser.d.ts +1 -0
  12. package/dist/connect-panel.d.ts +2 -1
  13. package/dist/connect.d.ts +7 -0
  14. package/dist/connect.js +791 -183
  15. package/dist/connect.js.map +1 -1
  16. package/dist/connection-catalog.d.ts +33 -0
  17. package/dist/connection-installed.d.ts +15 -0
  18. package/dist/connection-logo.d.ts +6 -0
  19. package/dist/connection-type-picker.d.ts +6 -0
  20. package/dist/index.js +17 -12
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugin-details.d.ts +14 -0
  23. package/dist/plugin-discovery.d.ts +14 -0
  24. package/dist/session-ui.d.ts +3 -0
  25. package/dist/session-ui.js +113 -2
  26. package/dist/session-ui.js.map +1 -1
  27. package/dist/skill-discovery.d.ts +29 -0
  28. package/dist/timeline/activity-rail.d.ts +3 -1
  29. package/dist/timeline/genie-loading.d.ts +24 -0
  30. package/dist/timeline/startup-preference.d.ts +3 -0
  31. package/dist/timeline/startup-timings.d.ts +4 -0
  32. package/package.json +4 -3
  33. package/src/components/message-timeline.tsx +127 -20
  34. package/src/connect-chooser.tsx +81 -21
  35. package/src/connect-panel.tsx +7 -1
  36. package/src/connect.ts +21 -0
  37. package/src/connection-catalog.tsx +153 -0
  38. package/src/connection-installed.tsx +45 -0
  39. package/src/connection-logo.tsx +29 -0
  40. package/src/connection-type-picker.tsx +36 -0
  41. package/src/hooks/use-codex-accounts.ts +1 -0
  42. package/src/plugin-details.tsx +182 -0
  43. package/src/plugin-discovery.tsx +161 -0
  44. package/src/session-ui.ts +3 -0
  45. package/src/skill-discovery.tsx +152 -0
  46. package/src/timeline/activity-rail.tsx +75 -4
  47. package/src/timeline/genie-loading.tsx +112 -0
  48. package/src/timeline/startup-preference.ts +33 -0
  49. package/src/timeline/startup-timings.tsx +145 -0
  50. package/src/timeline/turn-summary.tsx +8 -3
  51. package/styles/compiled.css +73 -0
  52. package/styles/connect.css +149 -1
  53. package/styles/index.css +15 -0
  54. package/dist/chunk-SCZ6SUBW.js.map +0 -1
  55. package/dist/chunk-Z7WBDB4E.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { ConnectionCatalog } from "./connection-catalog";
1
2
  import { useEffect, useState, type FormEvent } from "react";
2
3
  import type { ConnectController, ConnectProvider } from "@opengeni/connect";
3
4
  import { useConnect } from "./connect";
@@ -7,6 +8,7 @@ export type ConnectChooserProps = {
7
8
  /** Exact host return string; no completion parameters are added. */
8
9
  returnUrl: string;
9
10
  className?: string;
11
+ presentation?: "select" | "catalog";
10
12
  };
11
13
 
12
14
  /** Catalog readiness is supplied by the authenticated backend, never inferred
@@ -24,11 +26,17 @@ export function ConnectChooser(props: ConnectChooserProps) {
24
26
  return <ScopedChooser key={generation} {...props} />;
25
27
  }
26
28
 
27
- function ScopedChooser({ controller, returnUrl, className }: ConnectChooserProps) {
29
+ function ScopedChooser({
30
+ controller,
31
+ returnUrl,
32
+ className,
33
+ presentation = "select",
34
+ }: ConnectChooserProps) {
28
35
  const view = useConnect(controller);
29
36
  const [catalog, setCatalog] = useState<ConnectProvider[] | null>(null);
30
37
  const [failed, setFailed] = useState(false);
31
38
  const [load, setLoad] = useState(0);
39
+ const [query, setQuery] = useState("");
32
40
  const [providerId, setProviderId] = useState("");
33
41
  const [ownership, setOwnership] = useState("");
34
42
  useEffect(() => {
@@ -85,7 +93,52 @@ function ScopedChooser({ controller, returnUrl, className }: ConnectChooserProps
85
93
  <form onSubmit={submit}>
86
94
  <fieldset disabled={view.busy}>
87
95
  <legend>Provider and ownership</legend>
88
- <label>
96
+ {presentation === "catalog" && !provider ? (
97
+ <>
98
+ <label>
99
+ Search connections
100
+ <input
101
+ type="search"
102
+ value={query}
103
+ onChange={(event) => setQuery(event.target.value)}
104
+ />
105
+ </label>
106
+ <ConnectionCatalog
107
+ query={query}
108
+ services={catalog!.map((entry) => ({
109
+ id: entry.id,
110
+ name: entry.label,
111
+ options: [
112
+ {
113
+ id: entry.id,
114
+ name: entry.label,
115
+ description: entry.reason,
116
+ status: entry.readiness.replaceAll("_", " "),
117
+ connected: false,
118
+ onOpen: () => {
119
+ if (!view.busy) {
120
+ setProviderId(entry.id);
121
+ setOwnership(entry.ownership.length === 1 ? entry.ownership[0]! : "");
122
+ }
123
+ },
124
+ },
125
+ ],
126
+ }))}
127
+ />
128
+ </>
129
+ ) : null}
130
+ {presentation === "catalog" && provider ? (
131
+ <button
132
+ type="button"
133
+ onClick={() => {
134
+ setProviderId("");
135
+ setOwnership("");
136
+ }}
137
+ >
138
+ Back to connections
139
+ </button>
140
+ ) : null}
141
+ <label hidden={presentation === "catalog"}>
89
142
  Provider
90
143
  <select
91
144
  required
@@ -110,27 +163,34 @@ function ScopedChooser({ controller, returnUrl, className }: ConnectChooserProps
110
163
  </select>
111
164
  </label>
112
165
  {provider && (
113
- <label>
114
- Ownership
115
- <select
116
- required
117
- value={ownership}
118
- onChange={(event) => setOwnership(event.target.value)}
119
- >
120
- <option value="" disabled>
121
- Choose ownership
122
- </option>
123
- {provider.ownership.map((choice) => (
124
- <option key={choice} value={choice}>
125
- {choice === "personal"
126
- ? "Personal — owned by you"
127
- : "Workspace — shared connection"}
166
+ <>
167
+ <strong>{presentation === "catalog" ? provider.label : null}</strong>
168
+ <label>
169
+ Ownership
170
+ <select
171
+ required
172
+ value={ownership}
173
+ onChange={(event) => setOwnership(event.target.value)}
174
+ >
175
+ <option value="" disabled>
176
+ Choose ownership
128
177
  </option>
129
- ))}
130
- </select>
131
- </label>
178
+ {provider.ownership.map((choice) => (
179
+ <option key={choice} value={choice}>
180
+ {choice === "personal"
181
+ ? "Personal — owned by you"
182
+ : "Workspace — shared connection"}
183
+ </option>
184
+ ))}
185
+ </select>
186
+ </label>
187
+ </>
132
188
  )}
133
- <button type="submit" disabled={!canBegin}>
189
+ <button
190
+ hidden={presentation === "catalog" && !provider}
191
+ type="submit"
192
+ disabled={!canBegin}
193
+ >
134
194
  Start setup
135
195
  </button>
136
196
  </fieldset>
@@ -6,6 +6,7 @@ export type ConnectPanelProps = ConnectSetupProps & {
6
6
  returnUrl: string;
7
7
  title?: string;
8
8
  showAccounts?: boolean;
9
+ presentation?: "select" | "catalog";
9
10
  };
10
11
 
11
12
  /** Optional composition over the same unstyled surfaces. Import
@@ -15,13 +16,18 @@ export function ConnectPanel({
15
16
  returnUrl,
16
17
  title = "Connections",
17
18
  showAccounts = true,
19
+ presentation = "select",
18
20
  className,
19
21
  ...setup
20
22
  }: ConnectPanelProps) {
21
23
  return (
22
24
  <div className={["og-connect", className].filter(Boolean).join(" ")}>
23
25
  <h2>{title}</h2>
24
- <ConnectChooser controller={setup.controller} returnUrl={returnUrl} />
26
+ <ConnectChooser
27
+ presentation={presentation}
28
+ controller={setup.controller}
29
+ returnUrl={returnUrl}
30
+ />
25
31
  <ConnectSetup {...setup} />
26
32
  {showAccounts && <ConnectAccounts controller={setup.controller} returnUrl={returnUrl} />}
27
33
  </div>
package/src/connect.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  // The host owns controller lifetime; unmounting one observer does not cancel an
3
3
  // attempt another observer is displaying or a durable backend operation.
4
4
  import { useMemo, useSyncExternalStore } from "react";
5
+ export {
6
+ SkillDiscovery,
7
+ type SkillDiscoveryProps,
8
+ type SkillDiscoveryClient,
9
+ type SkillDiscoveryItem,
10
+ type SkillDiscoveryPage,
11
+ } from "./skill-discovery";
5
12
  import type { ConnectController } from "@opengeni/connect";
6
13
  export { ConnectSetup, type ConnectSetupProps } from "./connect-setup";
7
14
  export { ConnectChooser, type ConnectChooserProps } from "./connect-chooser";
@@ -46,3 +53,17 @@ export function useConnect(controller: ConnectController) {
46
53
  );
47
54
  return useMemo(() => ({ ...snapshot, ...actions }), [snapshot, actions]);
48
55
  }
56
+
57
+ export {
58
+ ConnectionCatalog,
59
+ ConnectionServiceRow,
60
+ ConnectionOptionRow,
61
+ type ConnectionCatalogProps,
62
+ type ConnectionCatalogService,
63
+ type ConnectionCatalogOption,
64
+ } from "./connection-catalog";
65
+ export { ConnectionInstalled, type ConnectionInstalledItem } from "./connection-installed";
66
+ export { ConnectionTypePicker, type ConnectionType } from "./connection-type-picker";
67
+ export { ConnectionLogo } from "./connection-logo";
68
+ export { PluginDiscovery, type PluginDiscoveryProps } from "./plugin-discovery";
69
+ export { PluginDetails } from "./plugin-details";
@@ -0,0 +1,153 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Status comes from the host/controller, never inferred from a display label. */
4
+ export type ConnectionCatalogOption = {
5
+ id: string;
6
+ name: string;
7
+ description?: string | undefined;
8
+ status: string;
9
+ connected: boolean;
10
+ onOpen: () => void;
11
+ action?: ReactNode;
12
+ };
13
+ export type ConnectionCatalogService = {
14
+ id: string;
15
+ name: string;
16
+ logo?: ReactNode;
17
+ options: ConnectionCatalogOption[];
18
+ };
19
+ export type ConnectionCatalogProps = {
20
+ services: ConnectionCatalogService[];
21
+ query?: string;
22
+ columns?: 1 | 2;
23
+ /** Group capabilities by service, or show every capability independently. */
24
+ grouped?: boolean;
25
+ className?: string;
26
+ emptyMessage?: string;
27
+ };
28
+
29
+ function ConnectionDetailsAction({
30
+ option,
31
+ name = option.name,
32
+ }: {
33
+ option: ConnectionCatalogOption;
34
+ name?: string;
35
+ }) {
36
+ return (
37
+ <button
38
+ type="button"
39
+ className="og-connection-details-action"
40
+ aria-label={`View ${name} details`}
41
+ onClick={option.onOpen}
42
+ >
43
+ <span aria-hidden>{option.connected ? "›" : "+"}</span>
44
+ </button>
45
+ );
46
+ }
47
+
48
+ export function ConnectionOptionRow({ option }: { option: ConnectionCatalogOption }) {
49
+ return (
50
+ <div className="og-connection-catalog-action-row og-connection-option">
51
+ <button type="button" className="og-connection-catalog-row" onClick={option.onOpen}>
52
+ <span className="og-connection-catalog-copy">
53
+ <strong>{option.name}</strong>
54
+ <span>{option.description}</span>
55
+ </span>
56
+ <span className="og-connection-catalog-status">{option.status}</span>
57
+ </button>
58
+ <span className="og-connection-option-action">
59
+ {option.action ?? <ConnectionDetailsAction option={option} />}
60
+ </span>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ export function ConnectionServiceRow({ service }: { service: ConnectionCatalogService }) {
66
+ if (!service.options.length) return null;
67
+ const connected = service.options.filter((option) => option.connected).length;
68
+ const identity = (
69
+ <>
70
+ <span className="og-connection-catalog-logo">{service.logo}</span>
71
+ <span className="og-connection-catalog-copy">
72
+ <strong>{service.name}</strong>
73
+ <span>
74
+ {service.options.length === 1
75
+ ? service.options[0]?.description
76
+ : service.options.map((option) => option.name).join(" · ")}
77
+ </span>
78
+ </span>
79
+ <span className="og-connection-catalog-status">
80
+ {service.options.length === 1
81
+ ? service.options[0]?.status
82
+ : `${connected} of ${service.options.length} connected`}
83
+ </span>
84
+ </>
85
+ );
86
+ return service.options.length === 1 ? (
87
+ <div className="og-connection-catalog-action-row">
88
+ <button
89
+ type="button"
90
+ className="og-connection-catalog-row"
91
+ onClick={service.options[0]!.onOpen}
92
+ >
93
+ {identity}
94
+ </button>
95
+ {service.options[0]?.action ?? (
96
+ <ConnectionDetailsAction option={service.options[0]!} name={service.name} />
97
+ )}
98
+ </div>
99
+ ) : (
100
+ <details className="og-connection-catalog-service">
101
+ <summary className="og-connection-catalog-row">
102
+ {identity}
103
+ <span aria-hidden className="og-connection-group-chevron">
104
+
105
+ </span>
106
+ </summary>
107
+ <div className="og-connection-catalog-options">
108
+ {service.options.map((option) => (
109
+ <ConnectionOptionRow key={option.id} option={option} />
110
+ ))}
111
+ </div>
112
+ </details>
113
+ );
114
+ }
115
+
116
+ export function ConnectionCatalog({
117
+ services,
118
+ query = "",
119
+ columns = 1,
120
+ grouped = true,
121
+ className = "",
122
+ emptyMessage = "No connections match your search.",
123
+ }: ConnectionCatalogProps) {
124
+ const search = query.trim().toLowerCase();
125
+ const entries = grouped
126
+ ? services
127
+ : services.flatMap((service) =>
128
+ service.options.map((option) => ({
129
+ ...service,
130
+ id: `${service.id}/${option.id}`,
131
+ name:
132
+ service.options.length > 1 && option.name !== service.name
133
+ ? `${service.name} · ${option.name}`
134
+ : service.name,
135
+ options: [option],
136
+ })),
137
+ );
138
+ const visible = entries.filter((service) =>
139
+ [service.name, ...service.options.flatMap((option) => [option.name, option.description ?? ""])]
140
+ .join(" ")
141
+ .toLowerCase()
142
+ .includes(search),
143
+ );
144
+ return (
145
+ <div className={`og-connection-catalog ${className}`} data-columns={columns}>
146
+ {visible.length ? (
147
+ visible.map((service) => <ConnectionServiceRow key={service.id} service={service} />)
148
+ ) : (
149
+ <p role="status">{emptyMessage}</p>
150
+ )}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type ConnectionInstalledItem = {
4
+ id: string;
5
+ name: string;
6
+ status: string;
7
+ icon?: ReactNode;
8
+ needsAttention?: boolean;
9
+ onOpen: () => void;
10
+ };
11
+
12
+ /** Compact account/service shortcuts. The host supplies authoritative status. */
13
+ export function ConnectionInstalled({
14
+ items,
15
+ title = "Connected",
16
+ className = "",
17
+ }: {
18
+ items: ConnectionInstalledItem[];
19
+ title?: string;
20
+ className?: string;
21
+ }) {
22
+ if (!items.length) return null;
23
+ return (
24
+ <section aria-label={title} className={`og-connection-installed ${className}`}>
25
+ <h2>
26
+ {title} <span>{items.length}</span>
27
+ </h2>
28
+ <div>
29
+ {items.map((item) => (
30
+ <button
31
+ key={item.id}
32
+ type="button"
33
+ onClick={item.onOpen}
34
+ title={item.status}
35
+ aria-label={`${item.name} · ${item.status}`}
36
+ >
37
+ {item.icon}
38
+ <span>{item.name}</span>
39
+ {item.needsAttention ? <span aria-label="Needs attention">!</span> : null}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ </section>
44
+ );
45
+ }
@@ -0,0 +1,29 @@
1
+ import { useState } from "react";
2
+ /** Brand identifier; failed/missing assets use a stable text fallback. */
3
+ export function ConnectionLogo({
4
+ src,
5
+ name,
6
+ size = 40,
7
+ }: {
8
+ src: string | null;
9
+ name: string;
10
+ size?: number;
11
+ }) {
12
+ const [failedSrc, setFailedSrc] = useState<string | null>(null);
13
+ return (
14
+ <span className="og-connection-logo" aria-hidden="true" style={{ width: size, height: size }}>
15
+ {src && src !== failedSrc ? (
16
+ <img src={src} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(src)} />
17
+ ) : (
18
+ <span>
19
+ {name
20
+ .split(/\s+/)
21
+ .map((part) => part[0])
22
+ .join("")
23
+ .slice(0, 2)
24
+ .toUpperCase()}
25
+ </span>
26
+ )}
27
+ </span>
28
+ );
29
+ }
@@ -0,0 +1,36 @@
1
+ import { useId } from "react";
2
+ export type ConnectionType = "mcp" | "openapi" | "graphql";
3
+ export function ConnectionTypePicker({
4
+ value,
5
+ onChange,
6
+ disabled = false,
7
+ }: {
8
+ value: ConnectionType;
9
+ onChange: (value: ConnectionType) => void;
10
+ disabled?: boolean;
11
+ }) {
12
+ const name = useId();
13
+ return (
14
+ <fieldset className="og-connection-type-picker" disabled={disabled}>
15
+ <legend>Connection type</legend>
16
+ {(
17
+ [
18
+ ["mcp", "MCP server"],
19
+ ["openapi", "OpenAPI"],
20
+ ["graphql", "GraphQL"],
21
+ ] as const
22
+ ).map(([type, label]) => (
23
+ <label key={type} data-selected={value === type}>
24
+ <input
25
+ type="radio"
26
+ name={name}
27
+ value={type}
28
+ checked={value === type}
29
+ onChange={() => onChange(type)}
30
+ />
31
+ {label}
32
+ </label>
33
+ ))}
34
+ </fieldset>
35
+ );
36
+ }
@@ -16,6 +16,7 @@ import {
16
16
  /** Events that change which Codex account a session runs on (or just ran). */
17
17
  export function isCodexAccountEvent(event: Pick<SessionEvent, "type">): boolean {
18
18
  return [
19
+ "codex.credential.selected",
19
20
  "codex.account.switched",
20
21
  "codex.account.selection.changed",
21
22
  "codex.capacity.waiting",
@@ -0,0 +1,182 @@
1
+ import { useState } from "react";
2
+ import { pluginMcpUnavailableReason, type PluginDiscoveryItem } from "@opengeni/sdk";
3
+ import { Markdown } from "./components/markdown";
4
+
5
+ const componentLabels: Record<string, string> = {
6
+ skills: "Skills",
7
+ mcp: "MCP connections",
8
+ apps: "Hosted apps",
9
+ hooks: "Hooks",
10
+ agents: "Agents",
11
+ commands: "Commands",
12
+ lsp: "Language servers",
13
+ };
14
+
15
+ const EMPTY_CONNECTIONS: Record<string, boolean> = {};
16
+
17
+ /** Embeddable plugin overview. Hosts own the dialog and installation actions. */
18
+ export function PluginDetails({
19
+ item,
20
+ onInstall,
21
+ busy = false,
22
+ installed = false,
23
+ error,
24
+ connections = EMPTY_CONNECTIONS,
25
+ onConnect,
26
+ }: {
27
+ item: PluginDiscoveryItem;
28
+ onInstall?: () => void;
29
+ busy?: boolean;
30
+ installed?: boolean;
31
+ error?: string | null;
32
+ connections?: Record<string, boolean>;
33
+ onConnect?: (server: { name: string; endpoint: string | null }) => void;
34
+ }) {
35
+ const supported = (server: NonNullable<PluginDiscoveryItem["mcpServers"]>[number]) =>
36
+ pluginMcpUnavailableReason(server) === null;
37
+ const contentsUnknown = item.skills == null || item.mcpServers == null;
38
+ const installableCount =
39
+ (item.skills?.length ?? 0) + (item.mcpServers?.filter(supported).length ?? 0);
40
+ const exceedsInstallLimit = installableCount > 64;
41
+ const installable = installableCount > 0 && !exceedsInstallLimit;
42
+ const [expanded, setExpanded] = useState(false);
43
+ const [logoFailed, setLogoFailed] = useState(false);
44
+ const description = item.longDescription || item.description;
45
+ const lengthy = description.length > 700;
46
+ const registryLabel =
47
+ item.provider === "openai"
48
+ ? "OpenAI plugin registry"
49
+ : item.provider === "anthropic"
50
+ ? "Anthropic plugin registry"
51
+ : "Custom source";
52
+ return (
53
+ <article className="og-plugin-details">
54
+ <header className="og-plugin-details-header">
55
+ {item.logoUrl && !logoFailed ? (
56
+ <img src={item.logoUrl} alt="" onError={() => setLogoFailed(true)} />
57
+ ) : null}
58
+ <div>
59
+ <h2>{item.displayName.replace(/-/g, " ")}</h2>
60
+ {item.author?.name ? <p>By {item.author.name}</p> : null}
61
+ </div>
62
+ </header>
63
+ <div className="og-plugin-details-meta">
64
+ <span>{registryLabel}</span>
65
+ {item.category ? <span>{item.category}</span> : null}
66
+ {item.version ? <span>v{item.version}</span> : null}
67
+ </div>
68
+ <section className="og-plugin-details-members">
69
+ <h3>
70
+ Skills <span>{item.skills?.length ?? "—"}</span>
71
+ </h3>
72
+ {item.skills?.length ? (
73
+ <ul>
74
+ {item.skills.map((skill) => (
75
+ <li key={skill.sourceUrl}>
76
+ <a href={skill.sourceUrl} target="_blank" rel="noopener noreferrer">
77
+ <strong>{skill.name}</strong>
78
+ <span aria-hidden="true">↗</span>
79
+ </a>
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ ) : (
84
+ <p>
85
+ {item.skills
86
+ ? "No bundled skills."
87
+ : "Contents haven’t been indexed for this repository."}
88
+ </p>
89
+ )}
90
+ <h3>
91
+ MCP servers <span>{item.mcpServers?.length ?? "—"}</span>
92
+ </h3>
93
+ {item.mcpServers?.length ? (
94
+ <ul>
95
+ {item.mcpServers.map((server) => {
96
+ const connected = Boolean(server.endpoint && connections[server.endpoint]);
97
+ // Existing connections remain manageable regardless of the source manifest's setup requirements.
98
+ const unavailableReason = connected ? null : pluginMcpUnavailableReason(server);
99
+ return (
100
+ <li
101
+ key={server.name}
102
+ className={unavailableReason ? "og-plugin-member-disabled" : undefined}
103
+ >
104
+ <div className="og-plugin-server-heading">
105
+ <strong>{server.name}</strong>
106
+ {!unavailableReason && onConnect ? (
107
+ <button type="button" disabled={busy} onClick={() => onConnect(server)}>
108
+ {connected ? "Manage" : "Connect"}
109
+ </button>
110
+ ) : null}
111
+ </div>
112
+ <small>
113
+ {connected ? "Connected · " : ""}
114
+ {unavailableReason ?? server.endpoint}
115
+ </small>
116
+ </li>
117
+ );
118
+ })}
119
+ </ul>
120
+ ) : (
121
+ <p>
122
+ {item.mcpServers ? "No bundled MCP servers." : "Server details haven’t been indexed."}
123
+ </p>
124
+ )}
125
+ {item.components?.some((component) => !["skills", "mcp"].includes(component)) ? (
126
+ <div className="og-plugin-details-other">
127
+ Not installed here:{" "}
128
+ {item.components
129
+ .filter((component) => !["skills", "mcp"].includes(component))
130
+ .map((component) => componentLabels[component] ?? component)
131
+ .join(", ")}
132
+ </div>
133
+ ) : null}
134
+ </section>
135
+ <section className="og-plugin-details-overview" aria-label="Overview">
136
+ <div className={lengthy && !expanded ? "og-plugin-details-excerpt" : undefined}>
137
+ <Markdown>{description}</Markdown>
138
+ </div>
139
+ {lengthy ? (
140
+ <button
141
+ type="button"
142
+ className="og-plugin-details-more"
143
+ aria-expanded={expanded}
144
+ onClick={() => setExpanded((value) => !value)}
145
+ >
146
+ {expanded ? "Show less" : "Read more"}
147
+ </button>
148
+ ) : null}
149
+ </section>
150
+ <footer>
151
+ {item.sourceUrl ? (
152
+ <a href={item.sourceUrl} target="_blank" rel="noopener noreferrer">
153
+ View repository <span aria-hidden="true">↗</span>
154
+ </a>
155
+ ) : null}
156
+ {onInstall ? (
157
+ <button
158
+ className="og-plugin-install"
159
+ type="button"
160
+ disabled={!installable || busy || installed}
161
+ onClick={onInstall}
162
+ >
163
+ {installed ? "Installed" : busy ? "Installing…" : "Install plugin"}
164
+ </button>
165
+ ) : null}
166
+ {error ? <p role="alert">{error}</p> : null}
167
+ {exceedsInstallLimit ? (
168
+ <p>This plugin exceeds the current 64-component installation limit.</p>
169
+ ) : contentsUnknown ? (
170
+ <p>
171
+ Plugin contents are unavailable. Installation is disabled until the repository can be
172
+ inspected.
173
+ </p>
174
+ ) : !installable ? (
175
+ <p>No installable skills or remote MCP servers.</p>
176
+ ) : (
177
+ <p>Installs skills and adds connection references. Connect each server separately.</p>
178
+ )}
179
+ </footer>
180
+ </article>
181
+ );
182
+ }