@opengeni/react 4.0.2 → 5.0.4-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 (53) hide show
  1. package/README.md +42 -0
  2. package/dist/artifacts.js +24 -285
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/chunk-BRFMGY4L.js +274 -0
  5. package/dist/chunk-BRFMGY4L.js.map +1 -0
  6. package/dist/chunk-IWIDV7SB.js +1 -0
  7. package/dist/chunk-IWIDV7SB.js.map +1 -0
  8. package/dist/{chunk-CQG7YUJB.js → chunk-SCZ6SUBW.js} +1177 -1021
  9. package/dist/chunk-SCZ6SUBW.js.map +1 -0
  10. package/dist/clipboard.js +1 -0
  11. package/dist/components/markdown-table-layout.d.ts +4 -1
  12. package/dist/components/message-timeline.d.ts +7 -3
  13. package/dist/connect-accounts.d.ts +10 -0
  14. package/dist/connect-chooser.d.ts +11 -0
  15. package/dist/connect-panel.d.ts +10 -0
  16. package/dist/connect-setup.d.ts +12 -0
  17. package/dist/connect.d.ts +30 -0
  18. package/dist/connect.js +831 -0
  19. package/dist/connect.js.map +1 -0
  20. package/dist/device-authorization.d.ts +15 -0
  21. package/dist/identity-link-accounts.d.ts +11 -0
  22. package/dist/identity-link-consent.d.ts +18 -0
  23. package/dist/index.js +523 -663
  24. package/dist/index.js.map +1 -1
  25. package/dist/{markdown-table-layout-3Z3AWBY7.js → markdown-table-layout-PFUEZ2U4.js} +7 -4
  26. package/dist/{markdown-table-layout-3Z3AWBY7.js.map → markdown-table-layout-PFUEZ2U4.js.map} +1 -1
  27. package/dist/session-ui.d.ts +2 -0
  28. package/dist/session-ui.js +3 -1
  29. package/dist/sites-ui.d.ts +33 -0
  30. package/dist/sites.d.ts +3 -0
  31. package/dist/sites.js +288 -0
  32. package/dist/sites.js.map +1 -0
  33. package/package.json +16 -2
  34. package/src/components/markdown-table-layout.ts +6 -3
  35. package/src/components/markdown.tsx +11 -3
  36. package/src/components/message-timeline.tsx +22 -5
  37. package/src/components/sandbox-workspace.tsx +9 -2
  38. package/src/connect-accounts.tsx +171 -0
  39. package/src/connect-chooser.tsx +144 -0
  40. package/src/connect-panel.tsx +29 -0
  41. package/src/connect-setup.tsx +260 -0
  42. package/src/connect.ts +48 -0
  43. package/src/device-authorization.tsx +90 -0
  44. package/src/hooks/use-workspace-capture.ts +3 -2
  45. package/src/identity-link-accounts.tsx +144 -0
  46. package/src/identity-link-consent.tsx +192 -0
  47. package/src/session-ui.ts +2 -0
  48. package/src/sites-ui.tsx +385 -0
  49. package/src/sites.ts +3 -0
  50. package/src/timeline/tool-renderers.tsx +1 -1
  51. package/styles/connect.css +48 -0
  52. package/styles/connect.d.ts +1 -0
  53. package/dist/chunk-CQG7YUJB.js.map +0 -1
@@ -0,0 +1,192 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type {
3
+ ExternalIdentityLink,
4
+ ConfirmExternalIdentityLinkRequest,
5
+ ExternalIdentityLinkPreview,
6
+ } from "@opengeni/sdk";
7
+
8
+ export type IdentityLinkClient = {
9
+ previewIdentityLink(
10
+ workspaceId: string,
11
+ linkId: string,
12
+ challenge: string,
13
+ ): Promise<ExternalIdentityLinkPreview>;
14
+ confirmIdentityLink(
15
+ workspaceId: string,
16
+ linkId: string,
17
+ input: ConfirmExternalIdentityLinkRequest,
18
+ ): Promise<ExternalIdentityLink>;
19
+ revokeIdentityLink(
20
+ workspaceId: string,
21
+ linkId: string,
22
+ expectedRevision: number,
23
+ ): Promise<ExternalIdentityLink>;
24
+ };
25
+ export type IdentityLinkConsentProps = {
26
+ client: IdentityLinkClient;
27
+ workspaceId: string;
28
+ linkId: string;
29
+ challenge: string;
30
+ /** Host label is explanatory, never proof of the requesting application. */
31
+ className?: string;
32
+ onComplete?: (link: ExternalIdentityLink) => void;
33
+ };
34
+
35
+ /** Optional native-login consent. Ordinary Connect does not use this surface.
36
+ * Permission choices only narrow the server's request; no automatic approval. */
37
+ export function IdentityLinkConsent({
38
+ client,
39
+ workspaceId,
40
+ linkId,
41
+ challenge,
42
+ className,
43
+ onComplete,
44
+ }: IdentityLinkConsentProps) {
45
+ const [link, setLink] = useState<ExternalIdentityLink | null>(null);
46
+ const [preview, setPreview] = useState<ExternalIdentityLinkPreview | null>(null);
47
+ const [selected, setSelected] = useState<ExternalIdentityLink["permissions"]>([]);
48
+ const [busy, setBusy] = useState(false);
49
+ const [error, setError] = useState(false);
50
+ const [retry, setRetry] = useState(0);
51
+ const generation = useRef(0);
52
+ useEffect(() => {
53
+ const current = ++generation.current;
54
+ setLink(null);
55
+ setPreview(null);
56
+ setSelected([]);
57
+ setBusy(true);
58
+ setError(false);
59
+ void client
60
+ .previewIdentityLink(workspaceId, linkId, challenge)
61
+ .then(
62
+ (result) => {
63
+ if (generation.current !== current) return;
64
+ setPreview(result);
65
+ setLink(result.link);
66
+ setSelected(result.link.permissions);
67
+ },
68
+ () => {
69
+ if (generation.current === current) setError(true);
70
+ },
71
+ )
72
+ .finally(() => {
73
+ if (generation.current === current) setBusy(false);
74
+ });
75
+ return () => {
76
+ // Invalidate live async work, rather than cleaning up a captured DOM node.
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ generation.current++;
79
+ };
80
+ }, [client, workspaceId, linkId, challenge, retry]);
81
+ async function mutate(revoke: boolean) {
82
+ if (!link || busy) return;
83
+ const current = generation.current;
84
+ setBusy(true);
85
+ setError(false);
86
+ try {
87
+ const result = revoke
88
+ ? await client.revokeIdentityLink(workspaceId, linkId, link.revision)
89
+ : await client.confirmIdentityLink(workspaceId, linkId, {
90
+ challenge,
91
+ expectedRevision: link.revision,
92
+ permissions: selected,
93
+ });
94
+ if (generation.current !== current) return;
95
+ setLink(result);
96
+ onComplete?.(result);
97
+ } catch {
98
+ if (generation.current === current) setError(true);
99
+ } finally {
100
+ if (generation.current === current) setBusy(false);
101
+ }
102
+ }
103
+ return (
104
+ <section
105
+ className={["og-connect og-identity-link", className].filter(Boolean).join(" ")}
106
+ aria-label="Link your account"
107
+ aria-busy={busy}
108
+ >
109
+ <h2>Link your OpenGeni account</h2>
110
+ <p>
111
+ Only continue if you started this request in a product you trust. Linking lets that product
112
+ act as your account with the permissions you select. It does not merge accounts or move
113
+ existing work.
114
+ </p>
115
+ {preview && (
116
+ <dl>
117
+ <dt>Organization</dt>
118
+ <dd>{preview.organizationId}</dd>
119
+ <dt>Product user</dt>
120
+ <dd>
121
+ {preview.externalIdentity.externalId} ({preview.externalIdentity.source})
122
+ </dd>
123
+ <dt>Your OpenGeni identity</dt>
124
+ <dd>{preview.nativeSubjectId}</dd>
125
+ </dl>
126
+ )}
127
+ {error && (
128
+ <div role="alert">
129
+ This request could not be completed. It may have expired, changed, or require you to sign
130
+ in to the right organization.
131
+ {!link && (
132
+ <button type="button" disabled={busy} onClick={() => setRetry((value) => value + 1)}>
133
+ Try again
134
+ </button>
135
+ )}
136
+ </div>
137
+ )}
138
+ {!link && busy && <p role="status">Loading the account-link request…</p>}
139
+ {link?.status === "pending" && (
140
+ <>
141
+ <p>
142
+ {link.expiresAt
143
+ ? `Access ends ${new Date(link.expiresAt).toLocaleString()}.`
144
+ : "Access lasts until you or the product revoke this link."}{" "}
145
+ Your current permissions still apply.
146
+ </p>
147
+ <fieldset disabled={busy}>
148
+ <legend>Allow these permissions</legend>
149
+ {link.permissions.map((permission) => (
150
+ <label key={permission}>
151
+ <input
152
+ type="checkbox"
153
+ checked={selected.includes(permission)}
154
+ onChange={(event) =>
155
+ setSelected((values) =>
156
+ event.target.checked
157
+ ? [...values, permission]
158
+ : values.filter((value) => value !== permission),
159
+ )
160
+ }
161
+ />
162
+ <span>{permission.replaceAll(":", " · ")}</span>
163
+ </label>
164
+ ))}
165
+ </fieldset>
166
+ <button
167
+ type="button"
168
+ disabled={busy || selected.length === 0}
169
+ onClick={() => void mutate(false)}
170
+ >
171
+ {busy ? "Saving…" : "Allow selected access"}
172
+ </button>
173
+ <p>You can close this page without granting access.</p>
174
+ </>
175
+ )}
176
+ {link?.status === "active" && (
177
+ <>
178
+ <p role="status">Account linked. Return to your product to continue.</p>
179
+ <button type="button" disabled={busy} onClick={() => void mutate(true)}>
180
+ Revoke this link
181
+ </button>
182
+ </>
183
+ )}
184
+ {link?.status === "revoked" && (
185
+ <p role="status">Link revoked. Your OpenGeni account and existing work are unchanged.</p>
186
+ )}
187
+ {link?.status === "expired" && (
188
+ <p role="status">This request has expired. Start a new request from your product.</p>
189
+ )}
190
+ </section>
191
+ );
192
+ }
package/src/session-ui.ts CHANGED
@@ -8,6 +8,8 @@ export type {
8
8
  HumanInputFormProps,
9
9
  } from "./components/human-input-form";
10
10
  export { HumanInputSurface } from "./components/human-input-surface";
11
+ export { ApprovalSurface } from "./components/approval-surface";
12
+ export type { ApprovalSurfaceProps, ApprovalSurfaceMessages } from "./components/approval-surface";
11
13
  export type { HumanInputSurfaceProps } from "./components/human-input-surface";
12
14
  export { MessageTimeline, TimelineRow } from "./components/message-timeline";
13
15
  export type { MessageTimelineProps } from "./components/message-timeline";
@@ -0,0 +1,385 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type {
3
+ OpenGeniClient,
4
+ WorkspaceArtifact,
5
+ WorkspaceArtifactContentResponse,
6
+ WorkspaceArtifactDetailResponse,
7
+ WorkspaceArtifactListResponse,
8
+ } from "@opengeni/sdk";
9
+ import {
10
+ PublishedHtmlArtifactFrame,
11
+ type PublishedHtmlArtifactToolBridge,
12
+ } from "./components/artifacts/published-html-artifact-frame";
13
+
14
+ /** Implement with an authenticated host proxy. Never put an organization key
15
+ * in browser props; replace the client object when the host actor changes. */
16
+ export type SiteClient = Pick<
17
+ OpenGeniClient,
18
+ | "listWorkspaceArtifacts"
19
+ | "getWorkspaceArtifact"
20
+ | "getWorkspaceArtifactHtml"
21
+ | "rollbackWorkspaceArtifact"
22
+ | "setWorkspaceArtifactStatus"
23
+ >;
24
+ type SiteDisplayContent = Pick<
25
+ WorkspaceArtifactContentResponse,
26
+ "artifactId" | "versionId" | "html" | "requestedTools"
27
+ >;
28
+ type SiteScope = { client: SiteClient; workspaceId: string; className?: string };
29
+ /** Shared native/embedded read boundary: pin content to the observed version,
30
+ * and never accept a response belonging to another workspace or Site. */
31
+ export async function loadSiteSnapshot(
32
+ client: Pick<SiteClient, "getWorkspaceArtifact" | "getWorkspaceArtifactHtml">,
33
+ workspaceId: string,
34
+ siteId: string,
35
+ options: { signal?: AbortSignal; includeArchivedContent?: boolean } = {},
36
+ ): Promise<{
37
+ detail: WorkspaceArtifactDetailResponse;
38
+ content: SiteDisplayContent | null;
39
+ }> {
40
+ const requestOptions = options.signal ? { signal: options.signal } : {};
41
+ const detail = await client.getWorkspaceArtifact(workspaceId, siteId, requestOptions);
42
+ options.signal?.throwIfAborted();
43
+ if (detail.artifact.id !== siteId || detail.artifact.workspaceId !== workspaceId)
44
+ throw new Error("Site scope mismatch");
45
+ const versionId = detail.artifact.currentVersion?.id;
46
+ const content =
47
+ versionId && (detail.artifact.status === "active" || options.includeArchivedContent)
48
+ ? {
49
+ artifactId: siteId,
50
+ versionId,
51
+ requestedTools: detail.artifact.currentVersion!.requestedTools,
52
+ html: await client.getWorkspaceArtifactHtml(workspaceId, siteId, {
53
+ ...requestOptions,
54
+ versionId,
55
+ }),
56
+ }
57
+ : null;
58
+ options.signal?.throwIfAborted();
59
+ if (content && (content.artifactId !== siteId || content.versionId !== versionId))
60
+ throw new Error("Site content mismatch");
61
+ return { detail: structuredClone(detail), content: content ? structuredClone(content) : null };
62
+ }
63
+ export type SiteListProps = SiteScope & {
64
+ status?: "active" | "archived";
65
+ onOpen: (site: WorkspaceArtifact) => void;
66
+ };
67
+ export type SiteDetailProps = SiteScope & {
68
+ siteId: string;
69
+ /** Presentation hint only: the backend still enforces artifacts:publish. */
70
+ canPublish?: boolean;
71
+ toolBridge?: PublishedHtmlArtifactToolBridge;
72
+ };
73
+
74
+ function useScopeKey(values: readonly unknown[]) {
75
+ const [scope, setScope] = useState(values);
76
+ const [generation, setGeneration] = useState(0);
77
+ if (values.some((value, index) => value !== scope[index])) {
78
+ setScope(values);
79
+ setGeneration(generation + 1);
80
+ }
81
+ return generation;
82
+ }
83
+
84
+ export function SiteList(props: SiteListProps) {
85
+ const key = useScopeKey([props.client, props.workspaceId, props.status]);
86
+ return <ScopedSiteList key={key} {...props} />;
87
+ }
88
+ function ScopedSiteList({
89
+ client,
90
+ workspaceId,
91
+ status = "active",
92
+ onOpen,
93
+ className,
94
+ }: SiteListProps) {
95
+ const [page, setPage] = useState<WorkspaceArtifactListResponse | null>(null);
96
+ const [cursor, setCursor] = useState<string | undefined>();
97
+ const [error, setError] = useState(false);
98
+ const [reload, setReload] = useState(0);
99
+ useEffect(() => {
100
+ const abort = new AbortController();
101
+ setPage(null);
102
+ setError(false);
103
+ void Promise.resolve()
104
+ .then(() =>
105
+ client.listWorkspaceArtifacts(workspaceId, {
106
+ status,
107
+ ...(cursor ? { cursor } : {}),
108
+ limit: 50,
109
+ signal: abort.signal,
110
+ }),
111
+ )
112
+ .then((result) => {
113
+ if (!abort.signal.aborted) setPage(structuredClone(result));
114
+ })
115
+ .catch(() => {
116
+ if (!abort.signal.aborted) setError(true);
117
+ });
118
+ return () => abort.abort();
119
+ }, [client, workspaceId, status, cursor, reload]);
120
+ return (
121
+ <section className={className} aria-label="Sites" aria-busy={!page && !error}>
122
+ {error ? (
123
+ <p role="alert">Sites are unavailable or access has changed. Refresh to check access.</p>
124
+ ) : !page ? (
125
+ <p role="status">Loading Sites…</p>
126
+ ) : page.artifacts.length === 0 ? (
127
+ <p role="status">No {status} Sites.</p>
128
+ ) : (
129
+ <ul>
130
+ {page.artifacts.map((site) => (
131
+ <li key={site.id}>
132
+ <button type="button" onClick={() => onOpen(structuredClone(site))}>
133
+ {site.title}
134
+ </button>
135
+ <p>{site.description}</p>
136
+ <span>
137
+ {site.status} · revision {site.currentVersion?.revision ?? "unpublished"}
138
+ </span>
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ )}
143
+ {page?.nextCursor && (
144
+ <button type="button" onClick={() => setCursor(page.nextCursor!)}>
145
+ Next page
146
+ </button>
147
+ )}
148
+ {cursor && (
149
+ <button type="button" onClick={() => setCursor(undefined)}>
150
+ First page
151
+ </button>
152
+ )}
153
+ <button type="button" onClick={() => setReload((value) => value + 1)}>
154
+ Refresh Sites
155
+ </button>
156
+ </section>
157
+ );
158
+ }
159
+
160
+ export function SiteDetail(props: SiteDetailProps) {
161
+ const key = useScopeKey([props.client, props.workspaceId, props.siteId]);
162
+ return <ScopedSiteDetail key={key} {...props} />;
163
+ }
164
+ function ScopedSiteDetail({
165
+ client,
166
+ workspaceId,
167
+ siteId,
168
+ canPublish = false,
169
+ toolBridge,
170
+ className,
171
+ }: SiteDetailProps) {
172
+ const [loaded, setLoaded] = useState<{
173
+ detail: WorkspaceArtifactDetailResponse;
174
+ content: SiteDisplayContent | null;
175
+ } | null>(null);
176
+ const [error, setError] = useState(false);
177
+ const [reload, setReload] = useState(0);
178
+ const [busy, setBusy] = useState(false);
179
+ const [rollbackId, setRollbackId] = useState("");
180
+ const [confirmation, setConfirmation] = useState<"status" | "rollback" | null>(null);
181
+ const mutation = useRef<AbortController | null>(null);
182
+ useEffect(() => () => mutation.current?.abort(), []);
183
+ useEffect(() => {
184
+ const abort = new AbortController();
185
+ setLoaded(null);
186
+ setError(false);
187
+ setConfirmation(null);
188
+ setRollbackId("");
189
+ void Promise.resolve()
190
+ .then(async () => {
191
+ const snapshot = await loadSiteSnapshot(client, workspaceId, siteId, {
192
+ signal: abort.signal,
193
+ });
194
+ if (!abort.signal.aborted) setLoaded(snapshot);
195
+ })
196
+ .catch(() => {
197
+ if (!abort.signal.aborted) {
198
+ setLoaded(null);
199
+ setError(true);
200
+ }
201
+ });
202
+ return () => abort.abort();
203
+ }, [client, workspaceId, siteId, reload]);
204
+ // Recheck live read authority without rebuilding the iframe on every tick.
205
+ // No overlapping polls, and failures remove the document and tool bridge.
206
+ useEffect(() => {
207
+ if (!loaded || busy) return;
208
+ const abort = new AbortController();
209
+ let checking = false;
210
+ const timer = setInterval(() => {
211
+ if (checking) return;
212
+ checking = true;
213
+ void Promise.resolve()
214
+ .then(() => client.getWorkspaceArtifact(workspaceId, siteId, { signal: abort.signal }))
215
+ .then((detail) => {
216
+ if (abort.signal.aborted) return;
217
+ if (detail.artifact.id !== siteId || detail.artifact.workspaceId !== workspaceId)
218
+ throw new Error("Site scope mismatch");
219
+ if (
220
+ detail.artifact.currentVersion?.id !== loaded.detail.artifact.currentVersion?.id ||
221
+ detail.artifact.status !== loaded.detail.artifact.status
222
+ ) {
223
+ setLoaded(null);
224
+ setReload((value) => value + 1);
225
+ }
226
+ })
227
+ .catch(() => {
228
+ if (!abort.signal.aborted) {
229
+ setLoaded(null);
230
+ setError(true);
231
+ }
232
+ })
233
+ .finally(() => {
234
+ checking = false;
235
+ });
236
+ }, 15_000);
237
+ return () => {
238
+ abort.abort();
239
+ clearInterval(timer);
240
+ };
241
+ }, [client, workspaceId, siteId, loaded, busy]);
242
+ const commit = async () => {
243
+ const current = loaded?.detail.artifact.currentVersion;
244
+ if (!canPublish || !loaded || !current || !confirmation || mutation.current) return;
245
+ const abort = new AbortController();
246
+ mutation.current = abort;
247
+ setBusy(true);
248
+ try {
249
+ const common = { expectedCurrentVersionId: current.id, idempotencyKey: crypto.randomUUID() };
250
+ if (confirmation === "rollback") {
251
+ if (!loaded.detail.versions.some((version) => version.id === rollbackId))
252
+ throw new Error("Unknown version");
253
+ await client.rollbackWorkspaceArtifact(
254
+ workspaceId,
255
+ siteId,
256
+ { ...common, versionId: rollbackId, reason: "Restore explicitly selected Site version" },
257
+ { signal: abort.signal },
258
+ );
259
+ } else {
260
+ await client.setWorkspaceArtifactStatus(
261
+ workspaceId,
262
+ siteId,
263
+ {
264
+ ...common,
265
+ status: loaded.detail.artifact.status === "active" ? "archived" : "active",
266
+ reason: "Explicit Site status change",
267
+ },
268
+ { signal: abort.signal },
269
+ );
270
+ }
271
+ if (!abort.signal.aborted) {
272
+ setLoaded(null);
273
+ setReload((value) => value + 1);
274
+ }
275
+ } catch {
276
+ if (!abort.signal.aborted) {
277
+ setLoaded(null);
278
+ setError(true);
279
+ }
280
+ } finally {
281
+ if (!abort.signal.aborted) {
282
+ mutation.current = null;
283
+ setBusy(false);
284
+ setConfirmation(null);
285
+ }
286
+ }
287
+ };
288
+ const site = loaded?.detail.artifact;
289
+ return (
290
+ <section
291
+ className={className}
292
+ aria-label="Site details"
293
+ aria-busy={busy || (!loaded && !error)}
294
+ >
295
+ {error && (
296
+ <p role="alert">
297
+ Site state could not be confirmed or access has changed. Refresh before continuing.
298
+ </p>
299
+ )}
300
+ {!loaded && !error && <p role="status">Loading Site…</p>}
301
+ {site && (
302
+ <>
303
+ <h2>{site.title}</h2>
304
+ <p>{site.description}</p>
305
+ <p>
306
+ {site.status} · revision {site.currentVersion?.revision ?? "unpublished"}
307
+ </p>
308
+ {loaded.content && (
309
+ <PublishedHtmlArtifactFrame
310
+ html={loaded.content.html}
311
+ title={site.title}
312
+ {...(toolBridge ? { toolBridge } : {})}
313
+ />
314
+ )}
315
+ <h3>Versions</h3>
316
+ <ul>
317
+ {loaded.detail.versions.map((version) => (
318
+ <li key={version.id}>
319
+ Revision {version.revision} · {version.createdAt}
320
+ {version.id === site.currentVersion?.id ? " · current" : ""}
321
+ </li>
322
+ ))}
323
+ </ul>
324
+ {loaded.detail.versionsTruncated && (
325
+ <p>Older versions are not included in this response.</p>
326
+ )}
327
+ {canPublish && site.currentVersion && (
328
+ <fieldset disabled={busy}>
329
+ <legend>Manage Site</legend>
330
+ <button type="button" onClick={() => setConfirmation("status")}>
331
+ {site.status === "active" ? "Archive Site" : "Restore Site"}
332
+ </button>
333
+ <label>
334
+ Version to restore
335
+ <select
336
+ value={rollbackId}
337
+ onChange={(event) => {
338
+ setRollbackId(event.target.value);
339
+ setConfirmation(null);
340
+ }}
341
+ >
342
+ <option value="">Choose a version</option>
343
+ {loaded.detail.versions
344
+ .filter((version) => version.id !== site.currentVersion?.id)
345
+ .map((version) => (
346
+ <option key={version.id} value={version.id}>
347
+ Revision {version.revision}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ </label>
352
+ <button
353
+ type="button"
354
+ disabled={!rollbackId}
355
+ onClick={() => setConfirmation("rollback")}
356
+ >
357
+ Review rollback
358
+ </button>
359
+ {confirmation && (
360
+ <div role="group" aria-label="Confirm Site change">
361
+ <p>
362
+ {confirmation === "rollback"
363
+ ? "Make the selected version current?"
364
+ : site.status === "active"
365
+ ? "Archive this Site for workspace users?"
366
+ : "Restore this Site for workspace users?"}
367
+ </p>
368
+ <button type="button" onClick={() => void commit()}>
369
+ Confirm change
370
+ </button>
371
+ <button type="button" onClick={() => setConfirmation(null)}>
372
+ Keep current state
373
+ </button>
374
+ </div>
375
+ )}
376
+ </fieldset>
377
+ )}
378
+ </>
379
+ )}
380
+ <button type="button" disabled={busy} onClick={() => setReload((value) => value + 1)}>
381
+ Refresh Site
382
+ </button>
383
+ </section>
384
+ );
385
+ }
package/src/sites.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Public package entry: keep TSX implementation behind a release-rewritable .ts boundary. */
2
+ export { SiteList, SiteDetail, loadSiteSnapshot } from "./sites-ui";
3
+ export type { SiteClient, SiteListProps, SiteDetailProps } from "./sites-ui";
@@ -2362,7 +2362,7 @@ function GenericToolIcon({ name }: { name: string }) {
2362
2362
  ? FileSearchIcon
2363
2363
  : leaf === "tool_search"
2364
2364
  ? PackageSearchIcon
2365
- : leaf === "load_skill"
2365
+ : leaf.startsWith("skill_")
2366
2366
  ? PlugIcon
2367
2367
  : WrenchIcon;
2368
2368
  return <Icon className={ICON_SIZE} />;
@@ -0,0 +1,48 @@
1
+ /* Opt-in, scoped styles. No document-level resets or Tailwind dependency. */
2
+ .og-connect {
3
+ font-family: var(--og-font-sans, system-ui, sans-serif);
4
+ color: inherit;
5
+ line-height: 1.55;
6
+ max-width: 48rem;
7
+ min-width: 0;
8
+ }
9
+ .og-connect :where(*, *::before, *::after) { box-sizing: border-box; }
10
+ .og-connect :where(select, input, label) { min-width: 0; }
11
+ .og-connect select { width: 100%; }
12
+ .og-connect :where(section, form, fieldset, ul, li, [role="group"]) {
13
+ display: grid;
14
+ gap: .75rem;
15
+ min-width: 0;
16
+ }
17
+ .og-connect section { padding-block: 1rem; }
18
+ .og-connect section + section { border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
19
+ .og-connect fieldset { border: 0; padding: 0; margin: 0; }
20
+ .og-connect legend { font-weight: 600; padding-block-end: .5rem; }
21
+ .og-connect label { display: grid; gap: .375rem; }
22
+ .og-connect label:has(input[type="checkbox"], input[type="radio"]) { display: flex; align-items: center; gap: .75rem; min-height: 2.75rem; }
23
+ .og-connect :where(button, select, input:not([type="checkbox"]):not([type="radio"])) {
24
+ font: inherit;
25
+ color: inherit;
26
+ background: transparent;
27
+ border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
28
+ border-radius: var(--og-radius-sm, 6px);
29
+ min-height: 2.75rem;
30
+ padding: .5rem .75rem;
31
+ max-width: 100%;
32
+ }
33
+ .og-connect button { cursor: pointer; width: fit-content; text-align: start; }
34
+ .og-identity-link { display: grid; gap: 1rem; }
35
+ .og-identity-link dl { margin: 0; display: grid; gap: .25rem; }
36
+ .og-identity-link dt { font-weight: 600; }
37
+ .og-identity-link dd { margin: 0 0 .75rem; overflow-wrap: anywhere; }
38
+ .og-identity-link label:has(input[type="checkbox"]) { min-height: 2.75rem; align-items: center; gap: .75rem; }
39
+ .og-connect button:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
40
+ .og-connect :disabled { opacity: .55; cursor: not-allowed; }
41
+ .og-connect :focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
42
+ .og-connect :where(p, h2) { margin: 0; overflow-wrap: anywhere; }
43
+ .og-connect ul { list-style: none; padding: 0; margin: 0; }
44
+ .og-connect li + li { padding-block-start: .75rem; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
45
+ .og-connect [role="alert"] { border-inline-start: 3px solid currentColor; padding-inline-start: .75rem; }
46
+ @media (forced-colors: active) {
47
+ .og-connect :where(button, select, input, section, li) { border-color: ButtonText; }
48
+ }
@@ -0,0 +1 @@
1
+ export {};