@opengeni/react 6.0.0-canary.0 → 6.1.0-canary.0

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 (68) hide show
  1. package/README.md +17 -0
  2. package/dist/capability-catalog-row.d.ts +20 -0
  3. package/dist/{chunk-VRBL3F5E.js → chunk-5GAL23FF.js} +10 -2
  4. package/dist/{chunk-VRBL3F5E.js.map → chunk-5GAL23FF.js.map} +1 -1
  5. package/dist/{chunk-IHQII5WO.js → chunk-G5JFM6ZE.js} +29 -4
  6. package/dist/chunk-G5JFM6ZE.js.map +1 -0
  7. package/dist/{chunk-UMECRJRV.js → chunk-HJ5VJ4JB.js} +21 -12
  8. package/dist/chunk-HJ5VJ4JB.js.map +1 -0
  9. package/dist/{chunk-R4KRSFGA.js → chunk-IP4BUBKH.js} +3 -3
  10. package/dist/{chunk-CC7AVRIJ.js → chunk-KZ4QDBGM.js} +87 -14
  11. package/dist/chunk-KZ4QDBGM.js.map +1 -0
  12. package/dist/{chunk-PZ27MCZP.js → chunk-L5VJYZIW.js} +34 -4
  13. package/dist/chunk-L5VJYZIW.js.map +1 -0
  14. package/dist/{chunk-45XEUVTK.js → chunk-UWVY3OXF.js} +12 -5
  15. package/dist/chunk-UWVY3OXF.js.map +1 -0
  16. package/dist/components/message-timeline.d.ts +2 -2
  17. package/dist/composer.js +4 -4
  18. package/dist/connect-panel.d.ts +2 -1
  19. package/dist/connect-setup.d.ts +3 -1
  20. package/dist/connect.d.ts +1 -0
  21. package/dist/connect.js +620 -424
  22. package/dist/connect.js.map +1 -1
  23. package/dist/connection-catalog.d.ts +6 -1
  24. package/dist/connection-installed.d.ts +1 -0
  25. package/dist/connection-logo.d.ts +4 -2
  26. package/dist/discovery-cache.d.ts +5 -0
  27. package/dist/index.js +7 -7
  28. package/dist/{model-policy-picker-menu-3R336DP2.js → model-policy-picker-menu-RVOQ4ZZG.js} +4 -4
  29. package/dist/model-policy.d.ts +6 -2
  30. package/dist/model-policy.js +1 -1
  31. package/dist/plugin-discovery.d.ts +4 -0
  32. package/dist/realtime.js +2 -2
  33. package/dist/session-ui.js +6 -6
  34. package/dist/session.js +1 -1
  35. package/dist/skill-discovery.d.ts +11 -0
  36. package/dist/timeline/projection.d.ts +3 -1
  37. package/dist/timeline/turn-summary.d.ts +2 -2
  38. package/package.json +3 -3
  39. package/src/capability-catalog-row.tsx +94 -0
  40. package/src/components/markdown.tsx +32 -3
  41. package/src/components/message-timeline.tsx +88 -4
  42. package/src/components/model-picker.tsx +11 -3
  43. package/src/components/model-policy-picker-menu.tsx +10 -3
  44. package/src/components/session-conversation.tsx +1 -3
  45. package/src/components/session-status.tsx +2 -2
  46. package/src/connect-panel.tsx +19 -8
  47. package/src/connect-setup.tsx +49 -14
  48. package/src/connect.ts +6 -0
  49. package/src/connection-catalog.tsx +61 -39
  50. package/src/connection-installed.tsx +2 -0
  51. package/src/connection-logo.tsx +14 -10
  52. package/src/discovery-cache.ts +42 -0
  53. package/src/hooks/use-session-events.ts +24 -2
  54. package/src/model-policy.ts +21 -0
  55. package/src/plugin-details.tsx +8 -5
  56. package/src/plugin-discovery.tsx +87 -71
  57. package/src/skill-discovery.tsx +101 -56
  58. package/src/timeline/projection.ts +31 -1
  59. package/src/timeline/turn-summary.tsx +3 -3
  60. package/styles/compiled.css +27 -0
  61. package/styles/connect.css +56 -15
  62. package/dist/chunk-45XEUVTK.js.map +0 -1
  63. package/dist/chunk-CC7AVRIJ.js.map +0 -1
  64. package/dist/chunk-IHQII5WO.js.map +0 -1
  65. package/dist/chunk-PZ27MCZP.js.map +0 -1
  66. package/dist/chunk-UMECRJRV.js.map +0 -1
  67. /package/dist/{chunk-R4KRSFGA.js.map → chunk-IP4BUBKH.js.map} +0 -0
  68. /package/dist/{model-policy-picker-menu-3R336DP2.js.map → model-policy-picker-menu-RVOQ4ZZG.js.map} +0 -0
@@ -84,9 +84,7 @@ function Conversation({
84
84
  onLoadOlder={feed.loadOlder}
85
85
  hasNewer={feed.hasNewer}
86
86
  loadingNewer={feed.loadingNewer}
87
- onLoadNewer={() => {
88
- void feed.loadNewer();
89
- }}
87
+ onLoadNewer={feed.loadNewer}
90
88
  onJumpToStart={async () => {
91
89
  await feed.loadOldest();
92
90
  }}
@@ -12,10 +12,10 @@ export type SessionStatusMeta = {
12
12
 
13
13
  export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta> = {
14
14
  queued: {
15
- label: "Starting",
15
+ label: "Queued",
16
16
  dotClassName: "bg-og-status-queued",
17
17
  badgeClassName: "text-og-fg-muted border-og-border bg-og-status-queued/10",
18
- pulse: true,
18
+ pulse: false,
19
19
  },
20
20
  running: {
21
21
  label: "Running",
@@ -1,10 +1,12 @@
1
1
  import { ConnectAccounts } from "./connect-accounts";
2
2
  import { ConnectChooser } from "./connect-chooser";
3
3
  import { ConnectSetup, type ConnectSetupProps } from "./connect-setup";
4
+ import { useConnect } from "./connect";
4
5
 
5
6
  export type ConnectPanelProps = ConnectSetupProps & {
6
7
  returnUrl: string;
7
- title?: string;
8
+ /** Null omits the heading when the host already supplies one. */
9
+ title?: string | null;
8
10
  showAccounts?: boolean;
9
11
  presentation?: "select" | "catalog";
10
12
  };
@@ -20,16 +22,25 @@ export function ConnectPanel({
20
22
  className,
21
23
  ...setup
22
24
  }: ConnectPanelProps) {
25
+ const { attempt } = useConnect(setup.controller);
26
+ // Unknown/failed outcomes still need reconciliation or cancellation, not a
27
+ // competing new acquisition. Unmounting observers never cancels controller work.
28
+ const activeSetup =
29
+ attempt !== null && !["complete", "cancelled", "expired"].includes(attempt.state);
23
30
  return (
24
31
  <div className={["og-connect", className].filter(Boolean).join(" ")}>
25
- <h2>{title}</h2>
26
- <ConnectChooser
27
- presentation={presentation}
28
- controller={setup.controller}
29
- returnUrl={returnUrl}
30
- />
32
+ {title !== null && <h2>{title}</h2>}
33
+ {!activeSetup && (
34
+ <ConnectChooser
35
+ presentation={presentation}
36
+ controller={setup.controller}
37
+ returnUrl={returnUrl}
38
+ />
39
+ )}
31
40
  <ConnectSetup {...setup} />
32
- {showAccounts && <ConnectAccounts controller={setup.controller} returnUrl={returnUrl} />}
41
+ {showAccounts && !activeSetup && (
42
+ <ConnectAccounts controller={setup.controller} returnUrl={returnUrl} />
43
+ )}
33
44
  </div>
34
45
  );
35
46
  }
@@ -38,9 +38,11 @@ export type ConnectSetupProps = {
38
38
  /** Host pagination must retain selections across pages and submit the final set. */
39
39
  onBrowseResources?: (attempt: ConnectAttempt) => void | Promise<unknown>;
40
40
  className?: string;
41
+ /** Provider-specific label supplied by the embedding host. */
42
+ authorizeLabel?: string | undefined;
41
43
  };
42
44
 
43
- /** Unstyled setup surface. No provider secrets enter React state. Host owns
45
+ /** Controller-backed setup surface, styled by opt-in connect.css. No provider secrets enter React state. Host owns
44
46
  * chooser, controller lifetime, navigation and recovery after full redirect. */
45
47
  export function ConnectSetup(props: ConnectSetupProps) {
46
48
  // Attempt IDs/revisions are not a substitute for actor scope. A host can
@@ -55,7 +57,13 @@ export function ConnectSetup(props: ConnectSetupProps) {
55
57
  return <ScopedSetup key={generation} {...props} />;
56
58
  }
57
59
 
58
- function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }: ConnectSetupProps) {
60
+ function ScopedSetup({
61
+ controller,
62
+ onAuthorize,
63
+ onBrowseResources,
64
+ className,
65
+ authorizeLabel = "Authorize connection",
66
+ }: ConnectSetupProps) {
59
67
  const view = useConnect(controller);
60
68
  const [localError, setLocalError] = useState(false);
61
69
  const invoke = (operation: () => unknown | Promise<unknown>) => {
@@ -69,7 +77,10 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
69
77
  const attempt = view.attempt;
70
78
  if (!attempt)
71
79
  return (
72
- <section className={className} aria-label="Connection setup">
80
+ <section
81
+ className={["og-connect-setup", className].filter(Boolean).join(" ")}
82
+ aria-label="Connection setup"
83
+ >
73
84
  <p role="status">Choose a connection to begin.</p>
74
85
  </section>
75
86
  );
@@ -115,9 +126,24 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
115
126
  }
116
127
  };
117
128
  return (
118
- <section className={className} aria-label="Connection setup" aria-busy={view.busy}>
119
- <p>Ownership: {attempt.ownership === "personal" ? "Personal" : "Workspace"}</p>
120
- <p role="status">{setupStatus[attempt.state]}</p>
129
+ <section
130
+ className={["og-connect-setup", className].filter(Boolean).join(" ")}
131
+ aria-label="Connection setup"
132
+ aria-busy={view.busy}
133
+ >
134
+ <div className="og-connect-setup-summary">
135
+ <p className="og-connect-setup-scope">
136
+ {attempt.ownership === "personal" ? "Personal connection" : "Workspace connection"}
137
+ </p>
138
+ <p role="status" className="og-connect-setup-status">
139
+ {setupStatus[attempt.state]}
140
+ </p>
141
+ <p className="og-connect-setup-description">
142
+ {attempt.ownership === "personal"
143
+ ? "Only your work can use this connection."
144
+ : "Work in this workspace can use this connection."}
145
+ </p>
146
+ </div>
121
147
  {attempt.account && <p>Account: {attempt.account.label}</p>}
122
148
  {(localError || view.error || attempt.error) && (
123
149
  <p role="alert">
@@ -129,10 +155,15 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
129
155
  {!terminal && (
130
156
  <form key={`${attempt.id}:${attempt.revision}`} onSubmit={submit} autoComplete="off">
131
157
  <fieldset disabled={view.busy}>
132
- <legend>Connection details</legend>
158
+ {["credentials", "select_account", "select_resources", "preview"].includes(
159
+ action.type,
160
+ ) ? (
161
+ <legend>Connection details</legend>
162
+ ) : null}
133
163
  {attempt.state === "connected_but_incomplete" && action.type === "none" && (
134
164
  <button
135
165
  type="button"
166
+ className="og-connect-setup-primary"
136
167
  onClick={() => invoke(() => view.advance({ type: "retry" }, crypto.randomUUID()))}
137
168
  >
138
169
  Review integration operations
@@ -218,6 +249,7 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
218
249
  ) && (
219
250
  <button
220
251
  type="submit"
252
+ className="og-connect-setup-primary"
221
253
  disabled={action.type === "select_resources" && Boolean(action.cursor)}
222
254
  >
223
255
  {action.type === "preview" ? "Install selected operations" : "Continue"}
@@ -226,9 +258,10 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
226
258
  {action.type === "authorize" && (
227
259
  <button
228
260
  type="button"
261
+ className="og-connect-setup-primary"
229
262
  onClick={() => invoke(() => onAuthorize(structuredClone(attempt)))}
230
263
  >
231
- Authorize connection
264
+ {authorizeLabel}
232
265
  </button>
233
266
  )}
234
267
  {action.type === "wait" && (
@@ -246,12 +279,14 @@ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }:
246
279
  )}
247
280
  </>
248
281
  )}
249
- <button type="button" onClick={() => invoke(() => view.refresh())}>
250
- Check status
251
- </button>
252
- <button type="button" onClick={() => invoke(() => view.cancel(crypto.randomUUID()))}>
253
- Cancel setup
254
- </button>
282
+ <div className="og-connect-setup-secondary">
283
+ <button type="button" onClick={() => invoke(() => view.refresh())}>
284
+ Check status
285
+ </button>
286
+ <button type="button" onClick={() => invoke(() => view.cancel(crypto.randomUUID()))}>
287
+ Cancel setup
288
+ </button>
289
+ </div>
255
290
  </fieldset>
256
291
  </form>
257
292
  )}
package/src/connect.ts CHANGED
@@ -2,6 +2,12 @@
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
+ CapabilityCatalogRow,
7
+ CapabilityCatalogIndicator,
8
+ type CapabilityCatalogRowProps,
9
+ type CapabilityCatalogStatus,
10
+ } from "./capability-catalog-row";
5
11
  export {
6
12
  SkillDiscovery,
7
13
  type SkillDiscoveryProps,
@@ -1,4 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
+ import {
3
+ CapabilityCatalogIndicator,
4
+ CapabilityCatalogRow,
5
+ type CapabilityCatalogStatus,
6
+ } from "./capability-catalog-row";
2
7
 
3
8
  /** Status comes from the host/controller, never inferred from a display label. */
4
9
  export type ConnectionCatalogOption = {
@@ -7,6 +12,8 @@ export type ConnectionCatalogOption = {
7
12
  description?: string | undefined;
8
13
  status: string;
9
14
  connected: boolean;
15
+ /** Explicit health/availability. Omit to retain visible legacy status text. */
16
+ state?: CapabilityCatalogStatus;
10
17
  onOpen: () => void;
11
18
  action?: ReactNode;
12
19
  };
@@ -20,31 +27,25 @@ export type ConnectionCatalogProps = {
20
27
  services: ConnectionCatalogService[];
21
28
  query?: string;
22
29
  columns?: 1 | 2;
30
+ resultLimit?: number;
31
+ onShowMore?: () => void;
23
32
  /** Group capabilities by service, or show every capability independently. */
24
33
  grouped?: boolean;
25
34
  className?: string;
26
35
  emptyMessage?: string;
27
36
  };
28
37
 
29
- function ConnectionDetailsIndicator() {
30
- return (
31
- <span aria-hidden className="og-connection-group-chevron">
32
-
33
- </span>
34
- );
35
- }
36
-
37
38
  export function ConnectionOptionRow({ option }: { option: ConnectionCatalogOption }) {
38
39
  return (
39
40
  <div className="og-connection-catalog-action-row og-connection-option">
40
- <button type="button" className="og-connection-catalog-row" onClick={option.onOpen}>
41
- <span className="og-connection-catalog-copy">
42
- <strong>{option.name}</strong>
43
- <span>{option.description}</span>
44
- </span>
45
- <span className="og-connection-catalog-status">{option.status}</span>
46
- {!option.action ? <ConnectionDetailsIndicator /> : null}
47
- </button>
41
+ <CapabilityCatalogRow
42
+ name={option.name}
43
+ description={option.description}
44
+ status={option.state ?? (option.connected ? "added" : "available")}
45
+ statusLabel={option.status}
46
+ showStatusLabel={option.state === undefined}
47
+ onOpen={option.onOpen}
48
+ />
48
49
  {option.action ? <span className="og-connection-option-action">{option.action}</span> : null}
49
50
  </div>
50
51
  );
@@ -53,10 +54,20 @@ export function ConnectionOptionRow({ option }: { option: ConnectionCatalogOptio
53
54
  export function ConnectionServiceRow({ service }: { service: ConnectionCatalogService }) {
54
55
  if (!service.options.length) return null;
55
56
  const connected = service.options.filter((option) => option.connected).length;
57
+ const attention = service.options.find(
58
+ (option) => option.state === "attention" || option.state === "unavailable",
59
+ );
60
+ const groupState =
61
+ attention?.state ??
62
+ (service.options.some((option) => option.state === "loading")
63
+ ? "loading"
64
+ : connected === service.options.length
65
+ ? "added"
66
+ : "available");
56
67
  const identity = (
57
68
  <>
58
- <span className="og-connection-catalog-logo">{service.logo}</span>
59
- <span className="og-connection-catalog-copy">
69
+ <span className="og-capability-catalog-icon">{service.logo}</span>
70
+ <span className="og-capability-catalog-copy">
60
71
  <strong>{service.name}</strong>
61
72
  <span>
62
73
  {service.options.length === 1
@@ -64,28 +75,28 @@ export function ConnectionServiceRow({ service }: { service: ConnectionCatalogSe
64
75
  : service.options.map((option) => option.name).join(" · ")}
65
76
  </span>
66
77
  </span>
67
- <span className="og-connection-catalog-status">
68
- {service.options.length === 1
69
- ? service.options[0]?.status
70
- : `${connected} of ${service.options.length} connected`}
71
- </span>
78
+ <CapabilityCatalogIndicator
79
+ status={groupState}
80
+ label={attention?.status ?? `${connected} of ${service.options.length} added`}
81
+ />
72
82
  </>
73
83
  );
74
84
  return service.options.length === 1 ? (
75
85
  <div className="og-connection-catalog-action-row">
76
- <button
77
- type="button"
78
- className="og-connection-catalog-row"
79
- onClick={service.options[0]!.onOpen}
80
- >
81
- {identity}
82
- {!service.options[0]?.action ? <ConnectionDetailsIndicator /> : null}
83
- </button>
86
+ <CapabilityCatalogRow
87
+ name={service.name}
88
+ description={service.options[0]!.description}
89
+ icon={service.logo}
90
+ status={service.options[0]!.state ?? (connected ? "added" : "available")}
91
+ statusLabel={service.options[0]!.status}
92
+ showStatusLabel={service.options[0]!.state === undefined}
93
+ onOpen={service.options[0]!.onOpen}
94
+ />
84
95
  {service.options[0]?.action}
85
96
  </div>
86
97
  ) : (
87
98
  <details className="og-connection-catalog-service">
88
- <summary className="og-connection-catalog-row">
99
+ <summary className="og-capability-catalog-row">
89
100
  {identity}
90
101
  <span aria-hidden className="og-connection-group-chevron">
91
102
 
@@ -105,6 +116,8 @@ export function ConnectionCatalog({
105
116
  query = "",
106
117
  columns = 1,
107
118
  grouped = true,
119
+ resultLimit,
120
+ onShowMore,
108
121
  className = "",
109
122
  emptyMessage = "No connections match your search.",
110
123
  }: ConnectionCatalogProps) {
@@ -129,12 +142,21 @@ export function ConnectionCatalog({
129
142
  .includes(search),
130
143
  );
131
144
  return (
132
- <div className={`og-connection-catalog ${className}`} data-columns={columns}>
133
- {visible.length ? (
134
- visible.map((service) => <ConnectionServiceRow key={service.id} service={service} />)
135
- ) : (
136
- <p role="status">{emptyMessage}</p>
137
- )}
138
- </div>
145
+ <>
146
+ <div className={`og-connection-catalog ${className}`} data-columns={columns}>
147
+ {visible.length ? (
148
+ visible
149
+ .slice(0, resultLimit)
150
+ .map((service) => <ConnectionServiceRow key={service.id} service={service} />)
151
+ ) : (
152
+ <p role="status">{emptyMessage}</p>
153
+ )}
154
+ </div>
155
+ {resultLimit && visible.length > resultLimit ? (
156
+ <button className="og-catalog-more" type="button" onClick={onShowMore}>
157
+ View all connections
158
+ </button>
159
+ ) : null}
160
+ </>
139
161
  );
140
162
  }
@@ -6,6 +6,7 @@ export type ConnectionInstalledItem = {
6
6
  status: string;
7
7
  icon?: ReactNode;
8
8
  needsAttention?: boolean;
9
+ disabled?: boolean;
9
10
  onOpen: () => void;
10
11
  };
11
12
 
@@ -31,6 +32,7 @@ export function ConnectionInstalled({
31
32
  key={item.id}
32
33
  type="button"
33
34
  onClick={item.onOpen}
35
+ disabled={item.disabled}
34
36
  title={item.status}
35
37
  aria-label={`${item.name} · ${item.status}`}
36
38
  >
@@ -1,13 +1,15 @@
1
- import { useState } from "react";
2
- /** Brand identifier; failed/missing assets use a stable text fallback. */
1
+ import { useState, type ReactNode } from "react";
2
+ /** Brand identifier; failed/missing assets use the supplied fallback or stable initials. */
3
3
  export function ConnectionLogo({
4
4
  src,
5
5
  name,
6
6
  size = 40,
7
+ fallback,
7
8
  }: {
8
9
  src: string | null;
9
10
  name: string;
10
11
  size?: number;
12
+ fallback?: ReactNode;
11
13
  }) {
12
14
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
13
15
  return (
@@ -15,14 +17,16 @@ export function ConnectionLogo({
15
17
  {src && src !== failedSrc ? (
16
18
  <img src={src} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(src)} />
17
19
  ) : (
18
- <span>
19
- {name
20
- .split(/\s+/)
21
- .map((part) => part[0])
22
- .join("")
23
- .slice(0, 2)
24
- .toUpperCase()}
25
- </span>
20
+ (fallback ?? (
21
+ <span>
22
+ {name
23
+ .split(/\s+/)
24
+ .map((part) => part[0])
25
+ .join("")
26
+ .slice(0, 2)
27
+ .toUpperCase()}
28
+ </span>
29
+ ))
26
30
  )}
27
31
  </span>
28
32
  );
@@ -0,0 +1,42 @@
1
+ /** Short-lived presentation data only. Never cache installation or authorization state. */
2
+ export function createDiscoveryCache<T>(ttlMs = 60_000, capacity = 64) {
3
+ const clients = new WeakMap<
4
+ object,
5
+ Map<string, { expires: number; value: T | undefined; pending: Promise<T> }>
6
+ >();
7
+ return {
8
+ peek(client: object, key: string): T | undefined {
9
+ const entry = clients.get(client)?.get(key);
10
+ return entry && entry.expires > Date.now() ? entry.value : undefined;
11
+ },
12
+ read(client: object, key: string, fetch: () => Promise<T>): Promise<T> {
13
+ let entries = clients.get(client);
14
+ if (!entries) {
15
+ entries = new Map();
16
+ clients.set(client, entries);
17
+ }
18
+ const existing = entries.get(key);
19
+ if (existing && existing.expires > Date.now()) return existing.pending;
20
+ entries.delete(key);
21
+ while (entries.size >= capacity) entries.delete(entries.keys().next().value!);
22
+ const entry = {
23
+ expires: Date.now() + ttlMs,
24
+ value: undefined as T | undefined,
25
+ pending: Promise.resolve().then(fetch),
26
+ };
27
+ entries.set(key, entry);
28
+ entry.pending = entry.pending.then(
29
+ (value) => {
30
+ entry.value = value;
31
+ entry.expires = Date.now() + ttlMs;
32
+ return value;
33
+ },
34
+ (error) => {
35
+ if (entries.get(key) === entry) entries.delete(key);
36
+ throw error;
37
+ },
38
+ );
39
+ return entry.pending;
40
+ },
41
+ };
42
+ }
@@ -140,6 +140,7 @@ export function useSessionEvents(
140
140
  const [eventWindow, setEventWindow] = useState<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
141
141
  const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
142
142
  const [error, setError] = useState<Error | null>(null);
143
+ const [newerError, setNewerError] = useState<Error | null>(null);
143
144
  const [hasOlder, setHasOlder] = useState(false);
144
145
  const [hasNewer, setHasNewer] = useState(false);
145
146
  const [sessionStatusProjection, setSessionStatusProjection] = useState<SessionStatus | null>(
@@ -186,6 +187,7 @@ export function useSessionEvents(
186
187
  // Navigation belongs to the session/client lifetime, not the transport.
187
188
  useEffect(() => {
188
189
  navigationGenerationRef.current += 1;
190
+ setNewerError(null);
189
191
  loadingOlderRef.current = false;
190
192
  loadingNewerRef.current = false;
191
193
  loadingOldestRef.current = false;
@@ -691,6 +693,7 @@ export function useSessionEvents(
691
693
  initialWindowLoadedRef.current = true;
692
694
  viewModeRef.current = "history";
693
695
  loadingOldestRef.current = false;
696
+ setNewerError(null);
694
697
  startTransition(() => {
695
698
  if (status !== undefined) {
696
699
  setSessionStatusProjection(status);
@@ -736,6 +739,7 @@ export function useSessionEvents(
736
739
  return false;
737
740
  }
738
741
  if (window.events.length === 0) {
742
+ setNewerError(null);
739
743
  hasNewerRef.current = false;
740
744
  setHasNewer(false);
741
745
  // Caught up with durable history — resume the live tip stream.
@@ -788,6 +792,7 @@ export function useSessionEvents(
788
792
  resumeLive = true;
789
793
  }
790
794
  loadingNewerRef.current = false;
795
+ setNewerError(null);
791
796
  startTransition(() => {
792
797
  if (status !== undefined) {
793
798
  setSessionStatusProjection(status);
@@ -803,6 +808,16 @@ export function useSessionEvents(
803
808
  });
804
809
  published = true;
805
810
  return newer;
811
+ } catch (reason) {
812
+ // A settled request from an old navigation lifetime is not a failure of
813
+ // the current session. Preserve current loading/error state as well.
814
+ if (navigationGenerationRef.current !== generation) {
815
+ return false;
816
+ }
817
+ setNewerError(reason instanceof Error ? reason : new Error(String(reason)));
818
+ // Keep authorization and integrity failures actionable for callers;
819
+ // timeline-owned invocations attach their own explicit recovery UI.
820
+ throw reason;
806
821
  } finally {
807
822
  if (!published && navigationGenerationRef.current === generation) {
808
823
  loadingNewerRef.current = false;
@@ -817,6 +832,7 @@ export function useSessionEvents(
817
832
  }
818
833
  loadingLatestRef.current = true;
819
834
  setLoadingLatest(true);
835
+ setNewerError(null);
820
836
  let published = false;
821
837
  try {
822
838
  streamAbortRef.current?.abort();
@@ -851,7 +867,13 @@ export function useSessionEvents(
851
867
 
852
868
  const identityMatches = stateStreamKey === streamKey;
853
869
  const visibleEvents = identityMatches ? eventWindow.events : EMPTY_EVENTS;
854
- const timeline = useMemo(() => buildTimeline(visibleEvents), [visibleEvents]);
870
+ const timeline = useMemo(
871
+ () =>
872
+ buildTimeline(visibleEvents, {
873
+ partialStart: hasOlder || eventWindow.truncated || after > 0,
874
+ }),
875
+ [visibleEvents, hasOlder, eventWindow.truncated, after],
876
+ );
855
877
 
856
878
  return {
857
879
  events: visibleEvents,
@@ -873,7 +895,7 @@ export function useSessionEvents(
873
895
  loadOldest,
874
896
  loadingLatest: !identityMatches ? false : loadingLatest,
875
897
  jumpToLatest,
876
- error: identityMatches ? error : null,
898
+ error: identityMatches ? (newerError ?? error) : null,
877
899
  };
878
900
  }
879
901
 
@@ -274,9 +274,11 @@ export function findPickerRow<TCatalog extends ClientModel>(
274
274
 
275
275
  export function groupPickerRowsByBillingClass(
276
276
  rows: PickerModelRow[],
277
+ options?: { codexOnly?: boolean },
277
278
  ): Array<{ billingClass: PickerBillingClass; label: string; rows: PickerModelRow[] }>;
278
279
  export function groupPickerRowsByBillingClass<TCatalog extends ClientModel>(
279
280
  rows: PickerModelRow<TCatalog>[],
281
+ options?: { codexOnly?: boolean },
280
282
  ): Array<{
281
283
  billingClass: PickerBillingClass;
282
284
  label: string;
@@ -284,6 +286,7 @@ export function groupPickerRowsByBillingClass<TCatalog extends ClientModel>(
284
286
  }>;
285
287
  export function groupPickerRowsByBillingClass<TCatalog extends ClientModel>(
286
288
  rows: PickerModelRow<TCatalog>[],
289
+ options?: { codexOnly?: boolean },
287
290
  ): Array<{
288
291
  billingClass: PickerBillingClass;
289
292
  label: string;
@@ -307,5 +310,23 @@ export function groupPickerRowsByBillingClass<TCatalog extends ClientModel>(
307
310
  rows: [row],
308
311
  });
309
312
  }
313
+ const codexIndex = groups.findIndex((group) => group.billingClass === "codex_subscription");
314
+ const codex = groups[codexIndex];
315
+ const selectableOpenGeni = groups
316
+ .find((group) => group.billingClass === "opengeni_credits")
317
+ ?.rows.filter((row) => row.selectable);
318
+ // Presentation only: never change the catalog/default model ordering or selection.
319
+ const onlyFreeOpenGeni =
320
+ selectableOpenGeni !== undefined &&
321
+ selectableOpenGeni.length > 0 &&
322
+ selectableOpenGeni.every((row) => row.catalog.cost === "free");
323
+ if (
324
+ codex &&
325
+ codex.rows.some((row) => row.selectable) &&
326
+ (options?.codexOnly || onlyFreeOpenGeni)
327
+ ) {
328
+ groups.splice(codexIndex, 1);
329
+ groups.unshift(codex);
330
+ }
310
331
  return groups;
311
332
  }
@@ -1,6 +1,8 @@
1
1
  import { useState } from "react";
2
2
  import { pluginMcpUnavailableReason, type PluginDiscoveryItem } from "@opengeni/sdk";
3
+ import { BoxesIcon } from "lucide-react";
3
4
  import { Markdown } from "./components/markdown";
5
+ import { ConnectionLogo } from "./connection-logo";
4
6
 
5
7
  const componentLabels: Record<string, string> = {
6
8
  skills: "Skills",
@@ -38,9 +40,8 @@ export function PluginDetails({
38
40
  const installableCount =
39
41
  (item.skills?.length ?? 0) + (item.mcpServers?.filter(supported).length ?? 0);
40
42
  const exceedsInstallLimit = installableCount > 64;
41
- const installable = installableCount > 0 && !exceedsInstallLimit;
43
+ const installable = !contentsUnknown && installableCount > 0 && !exceedsInstallLimit;
42
44
  const [expanded, setExpanded] = useState(false);
43
- const [logoFailed, setLogoFailed] = useState(false);
44
45
  const description = item.longDescription || item.description;
45
46
  const lengthy = description.length > 700;
46
47
  const registryLabel =
@@ -52,9 +53,11 @@ export function PluginDetails({
52
53
  return (
53
54
  <article className="og-plugin-details">
54
55
  <header className="og-plugin-details-header">
55
- {item.logoUrl && !logoFailed ? (
56
- <img src={item.logoUrl} alt="" onError={() => setLogoFailed(true)} />
57
- ) : null}
56
+ <ConnectionLogo
57
+ src={item.logoUrl}
58
+ name={item.displayName}
59
+ fallback={<BoxesIcon aria-hidden="true" />}
60
+ />
58
61
  <div>
59
62
  <h2>{item.displayName.replace(/-/g, " ")}</h2>
60
63
  {item.author?.name ? <p>By {item.author.name}</p> : null}