@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
@@ -0,0 +1,161 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { PluginDiscoveryItem, PluginDiscoveryPage } from "@opengeni/sdk";
3
+ export type PluginDiscoveryProps = {
4
+ client: {
5
+ discoverPlugins(
6
+ workspaceId: string,
7
+ options?: { query?: string; provider?: string; offset?: number },
8
+ ): Promise<PluginDiscoveryPage>;
9
+ };
10
+ workspaceId: string;
11
+ query: string;
12
+ onOpen: (item: PluginDiscoveryItem) => void;
13
+ };
14
+ export function PluginDiscovery(props: PluginDiscoveryProps) {
15
+ const [provider, setProvider] = useState("");
16
+ return (
17
+ <section className="og-plugin-discovery" aria-label="Discover plugins">
18
+ <header>
19
+ <h3>Browse plugins</h3>
20
+ <div className="og-plugin-filters" role="group" aria-label="Plugin registry">
21
+ {[
22
+ { value: "", label: "All" },
23
+ { value: "openai", label: "OpenAI plugin registry" },
24
+ { value: "anthropic", label: "Anthropic plugin registry" },
25
+ ].map((option) => (
26
+ <button
27
+ key={option.value}
28
+ type="button"
29
+ aria-pressed={provider === option.value}
30
+ onClick={() => setProvider(option.value)}
31
+ >
32
+ {option.label}
33
+ </button>
34
+ ))}
35
+ </div>
36
+ </header>
37
+ <Results
38
+ key={`${props.workspaceId}:${props.query}:${provider}`}
39
+ {...props}
40
+ provider={provider}
41
+ />
42
+ </section>
43
+ );
44
+ }
45
+ function Results({
46
+ client,
47
+ workspaceId,
48
+ query,
49
+ provider,
50
+ onOpen,
51
+ }: PluginDiscoveryProps & { provider: string }) {
52
+ const [items, setItems] = useState<PluginDiscoveryItem[]>([]);
53
+ const [offset, setOffset] = useState(0);
54
+ const [next, setNext] = useState<number | null>(null);
55
+ const [total, setTotal] = useState(0);
56
+ const [loading, setLoading] = useState(true);
57
+ const [error, setError] = useState(false);
58
+ const [retry, setRetry] = useState(0);
59
+ const sentinel = useRef<HTMLDivElement>(null);
60
+ useEffect(() => {
61
+ let active = true;
62
+ setLoading(true);
63
+ setError(false);
64
+ const timer = setTimeout(
65
+ () => {
66
+ void client.discoverPlugins(workspaceId, { query, provider, offset }).then(
67
+ (page) => {
68
+ if (!active) return;
69
+ setItems((previous) => (offset ? [...previous, ...page.items] : page.items));
70
+ setTotal(page.total);
71
+ setNext(page.nextOffset);
72
+ setLoading(false);
73
+ },
74
+ () => {
75
+ if (active) {
76
+ setError(true);
77
+ setLoading(false);
78
+ }
79
+ },
80
+ );
81
+ },
82
+ offset ? 0 : 200,
83
+ );
84
+ return () => {
85
+ active = false;
86
+ clearTimeout(timer);
87
+ };
88
+ }, [client, workspaceId, query, provider, offset, retry]);
89
+ useEffect(() => {
90
+ if (loading || error || next === null || !sentinel.current) return;
91
+ const observer = new IntersectionObserver(
92
+ (entries) => {
93
+ if (entries.some((entry) => entry.isIntersecting)) setOffset(next);
94
+ },
95
+ { rootMargin: "200px" },
96
+ );
97
+ observer.observe(sentinel.current);
98
+ return () => observer.disconnect();
99
+ }, [loading, error, next]);
100
+ return (
101
+ <>
102
+ {!loading && !error ? (
103
+ <p role="status">
104
+ {total ? `${total} plugins` : "No matching plugins. Try another search or registry."}
105
+ </p>
106
+ ) : null}
107
+ <div className="og-plugin-discovery-grid">
108
+ {items.map((item) => (
109
+ <button
110
+ key={item.id}
111
+ className="og-plugin-discovery-row"
112
+ type="button"
113
+ onClick={() => onOpen(item)}
114
+ >
115
+ {item.logoUrl ? (
116
+ <img
117
+ src={item.logoUrl}
118
+ alt=""
119
+ loading="lazy"
120
+ onError={(event) => {
121
+ event.currentTarget.style.display = "none";
122
+ }}
123
+ />
124
+ ) : null}
125
+ <span className="og-plugin-copy">
126
+ <span className="og-plugin-title">
127
+ <strong>{item.displayName.replace(/-/g, " ")}</strong>
128
+ <small>
129
+ {item.provider === "openai"
130
+ ? "OpenAI plugin registry"
131
+ : "Anthropic plugin registry"}
132
+ </small>
133
+ </span>
134
+ <span className="og-plugin-description">{item.description}</span>
135
+ {item.category ? <span className="og-plugin-category">{item.category}</span> : null}
136
+ </span>
137
+ <span className="og-plugin-open" aria-hidden="true">
138
+
139
+ </span>
140
+ </button>
141
+ ))}
142
+ </div>
143
+ {loading ? <p role="status">Loading plugins…</p> : null}
144
+ {error ? (
145
+ <p role="alert">
146
+ Couldn’t load plugins.{" "}
147
+ <button type="button" onClick={() => setRetry((value) => value + 1)}>
148
+ Retry
149
+ </button>
150
+ </p>
151
+ ) : null}
152
+ <div ref={sentinel}>
153
+ {next !== null && !loading && !error ? (
154
+ <button className="og-plugin-more" type="button" onClick={() => setOffset(next)}>
155
+ Show more plugins
156
+ </button>
157
+ ) : null}
158
+ </div>
159
+ </>
160
+ );
161
+ }
package/src/session-ui.ts CHANGED
@@ -43,3 +43,6 @@ export type {
43
43
  SessionChromeSignalTone,
44
44
  } from "./components/session-chrome";
45
45
  export { SessionCommandsPanel } from "./components/session-commands-panel";
46
+ export { StartupTimings } from "./timeline/startup-timings";
47
+ export { useStartupDetails, setStartupDetails } from "./timeline/startup-preference";
48
+ export type { GenieLoadingOptions } from "./timeline/genie-loading";
@@ -0,0 +1,152 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export type SkillDiscoveryItem = {
4
+ id: string;
5
+ name: string;
6
+ source: string;
7
+ installs: number;
8
+ url: string;
9
+ };
10
+ export type SkillDiscoveryPage = { items: SkillDiscoveryItem[]; nextCursor: null };
11
+ export type SkillDiscoveryClient = {
12
+ searchPublicSkills(workspaceId: string, query: string): Promise<SkillDiscoveryPage>;
13
+ };
14
+ export type SkillDiscoveryProps = {
15
+ installedSkills?: readonly { name: string; repositoryUrl: string; sourceUrl: string }[];
16
+ client: SkillDiscoveryClient;
17
+ workspaceId: string;
18
+ query: string;
19
+ onImport: (url: string) => void;
20
+ canManage: boolean;
21
+ onSearch?: (() => void) | undefined;
22
+ };
23
+
24
+ /** Host-owned query and import flow; browse presentation is reusable independently. */
25
+ export function SkillDiscovery(props: SkillDiscoveryProps) {
26
+ const query = props.query.trim();
27
+ return (
28
+ <section className="og-skill-discovery" aria-label="Discover skills">
29
+ <header>
30
+ <h3>Discover skills</h3>
31
+ <a
32
+ href="https://skills.sh/"
33
+ target="_blank"
34
+ rel="noopener noreferrer"
35
+ className="og-skill-discovery-browse"
36
+ >
37
+ Browse skills.sh ↗
38
+ </a>
39
+ </header>
40
+ {!query && props.onSearch ? (
41
+ <button type="button" className="og-skill-discovery-search" onClick={props.onSearch}>
42
+ Search skills
43
+ </button>
44
+ ) : null}
45
+ <DiscoveryResults key={`${props.workspaceId}:${query}`} {...props} query={query} />
46
+ </section>
47
+ );
48
+ }
49
+
50
+ const EMPTY_INSTALLED_SKILLS: NonNullable<SkillDiscoveryProps["installedSkills"]> = [];
51
+
52
+ function DiscoveryResults({
53
+ client,
54
+ workspaceId,
55
+ query,
56
+ canManage,
57
+ onImport,
58
+ installedSkills = EMPTY_INSTALLED_SKILLS,
59
+ }: SkillDiscoveryProps) {
60
+ const [result, setResult] = useState<SkillDiscoveryPage | null>(null);
61
+ const [loading, setLoading] = useState(query.length >= 2);
62
+ const [error, setError] = useState(false);
63
+ const [retry, setRetry] = useState(0);
64
+ useEffect(() => {
65
+ let active = true;
66
+ setError(false);
67
+ if (query.length < 2) return;
68
+ setLoading(true);
69
+ let timer: ReturnType<typeof setTimeout>;
70
+ const search = async (attempt: number) => {
71
+ try {
72
+ const data = await client.searchPublicSkills(workspaceId, query);
73
+ if (!active) return;
74
+ setResult(data);
75
+ setLoading(false);
76
+ } catch (cause) {
77
+ if (!active) return;
78
+ const status =
79
+ typeof cause === "object" && cause !== null && "status" in cause
80
+ ? cause.status
81
+ : undefined;
82
+ const transient =
83
+ cause instanceof TypeError ||
84
+ (typeof status === "number" && status >= 500 && status <= 599);
85
+ if (attempt === 0 && transient) {
86
+ timer = setTimeout(() => void search(1), 750);
87
+ return;
88
+ }
89
+ setError(true);
90
+ setLoading(false);
91
+ }
92
+ };
93
+ timer = setTimeout(() => void search(0), 300);
94
+ return () => {
95
+ active = false;
96
+ clearTimeout(timer);
97
+ };
98
+ }, [client, workspaceId, query, retry]);
99
+ return (
100
+ <div aria-busy={loading}>
101
+ <div className="og-skill-discovery-grid">
102
+ {result?.items.map((skill) => {
103
+ const installed = installedSkills.some(
104
+ (entry) =>
105
+ entry.sourceUrl.replace(/\/$/, "").toLowerCase() === skill.url.toLowerCase() ||
106
+ (entry.repositoryUrl.replace(/\/$/, "").toLowerCase() ===
107
+ `https://github.com/${skill.source}`.toLowerCase() &&
108
+ entry.name.toLowerCase() === skill.name.toLowerCase()),
109
+ );
110
+ return (
111
+ <button
112
+ key={skill.id}
113
+ type="button"
114
+ className="og-skill-discovery-item"
115
+ disabled={!canManage}
116
+ onClick={() => onImport(skill.url)}
117
+ >
118
+ <span className="og-skill-discovery-copy">
119
+ <strong>{skill.name}</strong>
120
+ <span>{skill.source}</span>
121
+ <small>{skill.installs.toLocaleString()} installs</small>
122
+ </span>
123
+ <span className="og-skill-discovery-action">
124
+ {installed ? (
125
+ <>
126
+ <span aria-hidden="true">✓ </span>Installed
127
+ </>
128
+ ) : !canManage ? (
129
+ "Admin required"
130
+ ) : (
131
+ "Preview →"
132
+ )}
133
+ </span>
134
+ </button>
135
+ );
136
+ })}
137
+ </div>
138
+ {loading ? <p role="status">Loading skills…</p> : null}
139
+ {error ? (
140
+ <div role="alert" className="og-skill-discovery-error">
141
+ Could not load skills.
142
+ <button type="button" onClick={() => setRetry((value) => value + 1)}>
143
+ Retry
144
+ </button>
145
+ </div>
146
+ ) : null}
147
+ {result && !result.items.length && !loading && !error ? (
148
+ <p role="status">No skills found. Try a different search.</p>
149
+ ) : null}
150
+ </div>
151
+ );
152
+ }
@@ -1,5 +1,8 @@
1
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
2
+ import { GenieLoading } from "./genie-loading";
3
+ import { useStartupDetails } from "./startup-preference";
1
4
  import { ArrowRightIcon, BotIcon, BrainCircuitIcon } from "lucide-react";
2
- import { lazy, Suspense, useLayoutEffect, useRef } from "react";
5
+ import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react";
3
6
  import { jsx as rowJsx, jsxs as rowJsxs } from "react/jsx-runtime";
4
7
  import { Markdown } from "../components/markdown";
5
8
  import { cn } from "../lib/cn";
@@ -28,6 +31,8 @@ const LazyPlatformActivityRow = lazy(() => import("./platform-activity-row"));
28
31
 
29
32
  export type ActivityRailProps = {
30
33
  items: ActivityItem[];
34
+ /** The owning turn remains active between individual phase receipts. */
35
+ startupActive?: boolean;
31
36
  /** Renderer registry for tool calls. Defaults to {@link defaultToolRegistry}. */
32
37
  toolRegistry?: ToolRegistry | undefined;
33
38
  /** Drill into a spawned worker session. */
@@ -60,6 +65,7 @@ function familyOf(item: ActivityItem): string {
60
65
 
61
66
  export function ActivityRail({
62
67
  items,
68
+ startupActive,
63
69
  toolRegistry = defaultToolRegistry,
64
70
  onOpenSession,
65
71
  onMemoryClick,
@@ -68,6 +74,37 @@ export function ActivityRail({
68
74
  bare,
69
75
  className,
70
76
  }: ActivityRailProps) {
77
+ const debug = useStartupDetails();
78
+ const reducedMotion = useReducedMotion();
79
+ const [detailsOpen, setDetailsOpen] = useState(false);
80
+ const phases = items.filter((item) => item.kind === "startup-phase");
81
+ // Empty reasoning envelopes can arrive before any visible model output.
82
+ const hasWork = items.some(
83
+ (item) =>
84
+ item.kind !== "startup-phase" && (item.kind !== "reasoning" || item.text.trim().length > 0),
85
+ );
86
+ const interrupted = phases.some(
87
+ (item) => item.status === "failed" || item.status === "cancelled",
88
+ );
89
+ const providerResponded = phases.some(
90
+ (item) => item.phase === "provider_first_byte" && item.status === "complete",
91
+ );
92
+ const preparing =
93
+ !hasWork &&
94
+ !interrupted &&
95
+ (startupActive ?? (!providerResponded && phases.some((item) => item.status === "running")));
96
+ const visibleItems =
97
+ debug || detailsOpen
98
+ ? items
99
+ : items.filter((item) =>
100
+ item.kind === "startup-phase"
101
+ ? item.status === "failed" || item.status === "cancelled"
102
+ : item.kind !== "reasoning" || item.text.trim().length > 0,
103
+ );
104
+ const startedAt = phases.reduce(
105
+ (first, item) => (item.startedAt < first ? item.startedAt : first),
106
+ phases[0]?.startedAt ?? "",
107
+ );
71
108
  const enterMounted = useEntranceAnimation();
72
109
  // Live gate: rails born during bulk capture enter=false forever; with a
73
110
  // seen-id map we still want later live appends to fade (ids gate remounts).
@@ -102,7 +139,7 @@ export function ActivityRail({
102
139
  // Rows sit TIGHT by default (gap-0.5) so a same-family run reads as one
103
140
  // calm cluster; a family change opens real breathing room (mt-3) below,
104
141
  // so a long rail reads as a few clusters, not a metronome of rows.
105
- "flex flex-col gap-0.5",
142
+ "relative flex flex-col gap-0.5",
106
143
  !bare && "border-l-2 border-og-border pl-3 sm:pl-4",
107
144
  // Whole-rail enter: standalone rails only (no seen-id map). Inside
108
145
  // MessageTimeline, unknown ids take per-row enter — remounts stay quiet.
@@ -110,8 +147,42 @@ export function ActivityRail({
110
147
  className,
111
148
  )}
112
149
  >
113
- {items.map((item, index) => {
114
- const newFamily = index > 0 && familyOf(item) !== familyOf(items[index - 1]!);
150
+ <AnimatePresence initial={false}>
151
+ {preparing && !debug ? (
152
+ <motion.div
153
+ key="startup"
154
+ initial={{ opacity: 0 }}
155
+ animate={{ opacity: 1, height: "auto" }}
156
+ exit={{
157
+ opacity: 0,
158
+ height: 0,
159
+ pointerEvents: "none",
160
+ }}
161
+ transition={{
162
+ height: { duration: reducedMotion ? 0 : 0.32, ease: [0.22, 1, 0.36, 1] },
163
+ opacity: { duration: reducedMotion ? 0 : 0.16 },
164
+ }}
165
+ style={{ overflow: "hidden" }}
166
+ >
167
+ <GenieLoading
168
+ startedAt={startedAt}
169
+ detailsOpen={detailsOpen}
170
+ onShowDetails={() => setDetailsOpen((open) => !open)}
171
+ />
172
+ </motion.div>
173
+ ) : null}
174
+ </AnimatePresence>
175
+ {detailsOpen && !debug && !preparing ? (
176
+ <button
177
+ type="button"
178
+ className="og-genie-details self-start"
179
+ onClick={() => setDetailsOpen(false)}
180
+ >
181
+ Hide startup details
182
+ </button>
183
+ ) : null}
184
+ {visibleItems.map((item, index) => {
185
+ const newFamily = index > 0 && familyOf(item) !== familyOf(visibleItems[index - 1]!);
115
186
  const row = renderActivity(
116
187
  item,
117
188
  toolRegistry,
@@ -0,0 +1,112 @@
1
+ import { ThinkingOrb } from "thinking-orbs";
2
+ import { useThemeType } from "../lib/use-theme-type";
3
+ import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
4
+
5
+ export type GenieLoadingRenderProps = {
6
+ startedAt: string;
7
+ detailsOpen: boolean;
8
+ onShowDetails: () => void;
9
+ };
10
+
11
+ export type GenieLoadingOptions = {
12
+ /** Replace the visual while preserving SDK loading visibility and transitions. */
13
+ render?: (props: GenieLoadingRenderProps) => ReactNode;
14
+ phrases?: readonly string[];
15
+ /** Public options stay independent of the renderer's declaration layout. */
16
+ orb?: {
17
+ state?:
18
+ | "working"
19
+ | "searching"
20
+ | "solving"
21
+ | "listening"
22
+ | "connecting"
23
+ | "weaving"
24
+ | "composing"
25
+ | "breathing"
26
+ | "shaping";
27
+ size?: 64 | 20;
28
+ speed?: number;
29
+ };
30
+ };
31
+ export const GenieLoadingOptionsContext = createContext<GenieLoadingOptions | undefined>(undefined);
32
+
33
+ const PHRASES = [
34
+ "Polishing the lamp…",
35
+ "Consulting the carpet…",
36
+ "Untangling wishes…",
37
+ "Summoning a little cleverness…",
38
+ "Checking the fine print on infinity…",
39
+ "Warming up the abracadabra…",
40
+ "Rearranging the stars…",
41
+ "Negotiating with the lamp…",
42
+ "Dusting off a thousand years…",
43
+ "Wishful thinking…",
44
+ "Decanting a little magic…",
45
+ "Finding the good stardust…",
46
+ "Fluffing the magic carpet…",
47
+ "Putting a wish into motion…",
48
+ "A little hocus. A little pocus…",
49
+ ];
50
+
51
+ /** Decorative copy never substitutes for a failure or claims measurable progress. */
52
+ export function GenieLoading({
53
+ startedAt,
54
+ onShowDetails,
55
+ detailsOpen = false,
56
+ }: {
57
+ startedAt: string;
58
+ onShowDetails: () => void;
59
+ detailsOpen?: boolean;
60
+ }) {
61
+ const options = useContext(GenieLoadingOptionsContext);
62
+ const phrases = options?.phrases?.length ? options.phrases : PHRASES;
63
+ const theme = useThemeType(undefined);
64
+ const [phrase, setPhrase] = useState(0);
65
+ const [showDetails, setShowDetails] = useState(false);
66
+ const [slow, setSlow] = useState(false);
67
+ useEffect(() => {
68
+ const update = () => {
69
+ setShowDetails(Date.now() - Date.parse(startedAt) >= 15_000);
70
+ setSlow(Date.now() - Date.parse(startedAt) >= 30_000);
71
+ if (!document.hidden) setPhrase(Math.floor(Math.random() * phrases.length));
72
+ };
73
+ update();
74
+ const timer = window.setInterval(update, 5_000);
75
+ return () => window.clearInterval(timer);
76
+ }, [startedAt, phrases]);
77
+ if (options?.render) return options.render({ startedAt, detailsOpen, onShowDetails });
78
+ return (
79
+ <div className="og-genie-loading">
80
+ <div
81
+ className="og-genie-orb"
82
+ aria-hidden="true"
83
+ style={{
84
+ width: options?.orb?.size ?? 64,
85
+ height: options?.orb?.size ?? 64,
86
+ flexBasis: options?.orb?.size ?? 64,
87
+ }}
88
+ >
89
+ <ThinkingOrb state="searching" size={64} theme={theme} speed={0.8} {...options?.orb} />
90
+ </div>
91
+ <div className="og-genie-copy">
92
+ <span className="sr-only" role="status">
93
+ {slow ? "Preparing your task. Taking longer than usual." : "Preparing your task."}
94
+ </span>
95
+ <span key={slow ? "slow" : phrase} className="og-genie-phrase" aria-hidden="true">
96
+ {slow ? "A little longer than usual…" : phrases[phrase % phrases.length]}
97
+ </span>
98
+ {showDetails || detailsOpen ? (
99
+ <button
100
+ type="button"
101
+ className="og-genie-details"
102
+ aria-expanded={detailsOpen}
103
+ onClick={onShowDetails}
104
+ >
105
+ {detailsOpen ? "Hide details" : "Behind the magic"}
106
+ <span aria-hidden="true"> ↗</span>
107
+ </button>
108
+ ) : null}
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,33 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ const KEY = "opengeni:startup-details:v1";
4
+ const EVENT = "opengeni:startup-details-changed";
5
+ let fallback = false;
6
+ function snapshot() {
7
+ try {
8
+ return window.localStorage.getItem(KEY) === "true";
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+ function subscribe(listener: () => void) {
14
+ window.addEventListener(EVENT, listener);
15
+ window.addEventListener("storage", listener);
16
+ return () => {
17
+ window.removeEventListener(EVENT, listener);
18
+ window.removeEventListener("storage", listener);
19
+ };
20
+ }
21
+ export function setStartupDetails(value: boolean) {
22
+ fallback = value;
23
+ try {
24
+ window.localStorage.setItem(KEY, String(value));
25
+ } catch {
26
+ /* Private storage is optional. */
27
+ }
28
+ window.dispatchEvent(new Event(EVENT));
29
+ }
30
+ /** Presentation preference only. Startup evidence is always retained. */
31
+ export function useStartupDetails() {
32
+ return useSyncExternalStore(subscribe, snapshot, () => false);
33
+ }