@opengeni/react 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,11 +40,13 @@
40
40
  "demo:build": "vite build demo"
41
41
  },
42
42
  "dependencies": {
43
- "@opengeni/sdk": "^0.2.0",
43
+ "@opengeni/sdk": "^0.3.1",
44
44
  "clsx": "^2.1.1",
45
45
  "lucide-react": "^1.8.0",
46
46
  "motion": "^12.0.0",
47
47
  "radix-ui": "^1.4.3",
48
+ "react-markdown": "^10.1.0",
49
+ "remark-gfm": "^4.0.1",
48
50
  "tailwind-merge": "^3.5.0"
49
51
  },
50
52
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -7,6 +7,8 @@ import type { OpenGeniClient } from "@opengeni/sdk";
7
7
  */
8
8
  export type SessionClientLike = Pick<
9
9
  OpenGeniClient,
10
+ // Deployment config (host-exposed models, auth, upload limits)
11
+ | "getClientConfig"
10
12
  // Sessions, events, composer
11
13
  | "getSession"
12
14
  | "listSessions"
@@ -1,4 +1,4 @@
1
- import type { SessionStatus } from "@opengeni/sdk";
1
+ import type { ClientModel, SessionStatus } from "@opengeni/sdk";
2
2
  import { ArrowUpIcon, FileIcon, ImageIcon, LoaderCircleIcon, PaperclipIcon, SquareIcon, XIcon } from "lucide-react";
3
3
  import { AnimatePresence, motion } from "motion/react";
4
4
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type KeyboardEvent, type ReactNode } from "react";
@@ -12,6 +12,7 @@ import { useSlashCommands, type ConfirmState, type SlashCommandContext } from ".
12
12
  import { cn } from "../lib/cn";
13
13
  import { formatBytes } from "../lib/format";
14
14
  import { CommandPalette } from "./command-palette";
15
+ import { ModelPicker } from "./model-picker";
15
16
 
16
17
  export type ChatComposerProps = {
17
18
  composer: ComposerState;
@@ -37,6 +38,20 @@ export type ChatComposerProps = {
37
38
  * Absent → no attachment UI renders and the composer behaves exactly as before.
38
39
  */
39
40
  attachments?: UseFileAttachmentsResult | undefined;
41
+ /**
42
+ * Opt-in model picker. When supplied (e.g. from {@link useAvailableModels}),
43
+ * the composer renders a {@link ModelPicker} at the start of `controlsStart`
44
+ * so the operator can choose which host-exposed model serves the next message.
45
+ * The host owns the selection (`selectedModel`/`onSelectModel`) and is
46
+ * responsible for threading it into the composer's `sendExtras` (typically
47
+ * `useComposer({ sendExtras: () => ({ model }) })`) so `composeSendInput`
48
+ * carries it. Absent → no picker renders and the composer behaves as before.
49
+ */
50
+ models?: ClientModel[] | undefined;
51
+ /** The currently selected model id (the picker's controlled value). */
52
+ selectedModel?: string | undefined;
53
+ /** Called with the chosen model id when the operator picks one. */
54
+ onSelectModel?: ((modelId: string) => void) | undefined;
40
55
  className?: string | undefined;
41
56
  /**
42
57
  * Slash-command palette. Defaults to the built-in {@link defaultCommands};
@@ -77,6 +92,9 @@ export function ChatComposer({
77
92
  header,
78
93
  onPaste,
79
94
  attachments,
95
+ models,
96
+ selectedModel,
97
+ onSelectModel,
80
98
  className,
81
99
  commands = defaultCommands,
82
100
  commandContext,
@@ -376,7 +394,7 @@ export function ChatComposer({
376
394
  />
377
395
  ) : (
378
396
  <div className="flex items-center justify-between gap-2 px-2.5 pb-2.5 pt-1">
379
- {attachments || controlsStart ? (
397
+ {attachments || models || controlsStart ? (
380
398
  <span className="flex min-w-0 items-center gap-1.5">
381
399
  {attachments ? (
382
400
  <>
@@ -403,6 +421,14 @@ export function ChatComposer({
403
421
  </button>
404
422
  </>
405
423
  ) : null}
424
+ {models ? (
425
+ <ModelPicker
426
+ models={models}
427
+ value={selectedModel}
428
+ onChange={(modelId) => onSelectModel?.(modelId)}
429
+ disabled={disabled === true}
430
+ />
431
+ ) : null}
406
432
  {controlsStart}
407
433
  </span>
408
434
  ) : (
@@ -0,0 +1,170 @@
1
+ import { memo } from "react";
2
+ import ReactMarkdown, { type Components } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { cn } from "../lib/cn";
5
+
6
+ /**
7
+ * The default renderer for chat message bodies in {@link MessageTimeline}.
8
+ *
9
+ * Agent (and user) messages arrive as GitHub-flavored markdown. This turns the
10
+ * raw text into styled HTML using `react-markdown` + `remark-gfm`, themed to the
11
+ * package's `og-*` design tokens so it reads as one cohesive dark surface — no
12
+ * stock Tailwind colors leak in.
13
+ *
14
+ * It re-parses on every render, which is exactly right for streaming: a body
15
+ * that is still arriving (an unterminated `**`, a half-open code fence, a table
16
+ * mid-row) renders as best-effort markdown and resolves cleanly as the rest of
17
+ * the tokens land. Consumers who want a different renderer can still pass
18
+ * `renderMessageText` to `MessageTimeline` to override this entirely.
19
+ */
20
+ export type MarkdownProps = {
21
+ children: string;
22
+ className?: string | undefined;
23
+ };
24
+
25
+ /* --- element renderers (themed to og-* tokens) ------------------------------ */
26
+
27
+ const components: Components = {
28
+ h1: ({ children, ...props }) => (
29
+ <h1 className="mt-5 mb-2.5 text-xl font-semibold tracking-tight text-og-fg first:mt-0" {...props}>
30
+ {children}
31
+ </h1>
32
+ ),
33
+ h2: ({ children, ...props }) => (
34
+ <h2 className="mt-5 mb-2 text-lg font-semibold tracking-tight text-og-fg first:mt-0" {...props}>
35
+ {children}
36
+ </h2>
37
+ ),
38
+ h3: ({ children, ...props }) => (
39
+ <h3 className="mt-4 mb-1.5 text-[15px] font-semibold tracking-tight text-og-fg first:mt-0" {...props}>
40
+ {children}
41
+ </h3>
42
+ ),
43
+ h4: ({ children, ...props }) => (
44
+ <h4 className="mt-4 mb-1.5 text-sm font-semibold uppercase tracking-[0.04em] text-og-fg-muted first:mt-0" {...props}>
45
+ {children}
46
+ </h4>
47
+ ),
48
+ p: ({ children, ...props }) => (
49
+ <p className="my-2.5 leading-7 first:mt-0 last:mb-0" {...props}>
50
+ {children}
51
+ </p>
52
+ ),
53
+ strong: ({ children, ...props }) => (
54
+ <strong className="font-semibold text-og-fg" {...props}>
55
+ {children}
56
+ </strong>
57
+ ),
58
+ em: ({ children, ...props }) => (
59
+ <em className="italic" {...props}>
60
+ {children}
61
+ </em>
62
+ ),
63
+ a: ({ children, ...props }) => (
64
+ <a
65
+ className="break-words font-medium text-og-accent underline-offset-2 hover:underline"
66
+ target="_blank"
67
+ rel="noreferrer noopener"
68
+ {...props}
69
+ >
70
+ {children}
71
+ </a>
72
+ ),
73
+ ul: ({ children, ...props }) => (
74
+ <ul className="my-2.5 ml-5 flex list-disc flex-col gap-1 marker:text-og-fg-subtle first:mt-0 last:mb-0" {...props}>
75
+ {children}
76
+ </ul>
77
+ ),
78
+ ol: ({ children, ...props }) => (
79
+ <ol className="my-2.5 ml-5 flex list-decimal flex-col gap-1 marker:text-og-fg-subtle first:mt-0 last:mb-0" {...props}>
80
+ {children}
81
+ </ol>
82
+ ),
83
+ // GFM task-list items carry a leading checkbox <input>; `list-none` + a
84
+ // negative margin pull the checkbox back to the bullet column so it aligns
85
+ // with the text.
86
+ li: ({ children, ...props }) => (
87
+ <li className="leading-7 marker:text-og-fg-subtle [&>ul]:my-1 [&>ol]:my-1 [&:has(>input)]:list-none [&:has(>input)]:-ml-5" {...props}>
88
+ {children}
89
+ </li>
90
+ ),
91
+ input: ({ type, ...props }) =>
92
+ type === "checkbox" ? (
93
+ <input
94
+ {...props}
95
+ type="checkbox"
96
+ disabled
97
+ className="mr-2 size-3.5 translate-y-[2px] cursor-default appearance-none rounded-[3px] border border-og-border bg-og-surface-1 align-baseline checked:border-og-accent checked:bg-og-accent checked:[background-image:url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20d%3D%22M2.5%206.2l2.2%202.2%204.6-4.8%22%2F%3E%3C%2Fsvg%3E')] checked:bg-[length:11px_11px] checked:bg-center checked:bg-no-repeat"
98
+ />
99
+ ) : (
100
+ <input type={type} {...props} />
101
+ ),
102
+ blockquote: ({ children, ...props }) => (
103
+ <blockquote
104
+ className="my-3 border-l-2 border-og-border-strong pl-3.5 text-og-fg-muted [&>p]:my-1.5 first:mt-0 last:mb-0"
105
+ {...props}
106
+ >
107
+ {children}
108
+ </blockquote>
109
+ ),
110
+ hr: (props) => <hr className="my-4 border-0 border-t border-og-border" {...props} />,
111
+ // Inline `code` vs fenced code blocks. react-markdown v10 no longer passes an
112
+ // `inline` flag; a fenced block is a <code> whose parent is <pre> (styled by
113
+ // the `pre` renderer), so a `code` reaching here is treated as inline.
114
+ code: ({ children, className: _className, ...props }) => (
115
+ <code
116
+ className="rounded-og-xs border border-og-border bg-og-surface-1 px-1 py-0.5 font-og-mono text-[0.85em] text-og-fg"
117
+ {...props}
118
+ >
119
+ {children}
120
+ </code>
121
+ ),
122
+ // Fenced code blocks — mirror the timeline's PayloadBlock <pre> styling for
123
+ // visual consistency (bordered, scrollable, mono, surface background).
124
+ pre: ({ children, ...props }) => (
125
+ <pre
126
+ className="my-3 max-h-96 overflow-auto rounded-og-md border border-og-border bg-og-bg/60 p-3 font-og-mono text-[12.5px] leading-5 text-og-fg-muted [&>code]:border-0 [&>code]:bg-transparent [&>code]:p-0 [&>code]:text-inherit first:mt-0 last:mb-0"
127
+ {...props}
128
+ >
129
+ {children}
130
+ </pre>
131
+ ),
132
+ table: ({ children, ...props }) => (
133
+ <div className="my-3 max-w-full overflow-x-auto rounded-og-md border border-og-border first:mt-0 last:mb-0">
134
+ <table className="w-full border-collapse text-[13px]" {...props}>
135
+ {children}
136
+ </table>
137
+ </div>
138
+ ),
139
+ thead: ({ children, ...props }) => (
140
+ <thead className="bg-og-surface-1" {...props}>
141
+ {children}
142
+ </thead>
143
+ ),
144
+ th: ({ children, ...props }) => (
145
+ <th className="border-b border-og-border px-3 py-1.5 text-left font-medium text-og-fg" {...props}>
146
+ {children}
147
+ </th>
148
+ ),
149
+ td: ({ children, ...props }) => (
150
+ <td className="border-b border-og-border px-3 py-1.5 align-top text-og-fg-muted [tr:last-child>&]:border-b-0" {...props}>
151
+ {children}
152
+ </td>
153
+ ),
154
+ img: ({ alt, ...props }) => <img alt={alt ?? ""} className="my-3 max-w-full rounded-og-md border border-og-border" {...props} />,
155
+ };
156
+
157
+ function MarkdownImpl({ children, className }: MarkdownProps) {
158
+ return (
159
+ // `min-w-0` lets the prose shrink inside flex parents (message bubbles) so
160
+ // long links and code blocks wrap/scroll instead of forcing overflow.
161
+ <div className={cn("min-w-0 break-words", className)}>
162
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
163
+ {children}
164
+ </ReactMarkdown>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ /** Memoized so streaming re-renders of the parent don't re-parse settled bodies. */
170
+ export const Markdown = memo(MarkdownImpl);
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
14
14
  import { Collapsible } from "radix-ui";
15
15
  import { cn } from "../lib/cn";
16
16
  import { formatRelativeTime, stringifyPayload, truncate } from "../lib/format";
17
+ import { Markdown } from "./markdown";
17
18
  import {
18
19
  buildTimeline,
19
20
  compactPayloadPreview,
@@ -175,8 +176,8 @@ function UserMessageRow({
175
176
  }) {
176
177
  return (
177
178
  <div className="animate-og-enter flex justify-end">
178
- <div className="max-w-[85%] rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-[15px] leading-6 text-og-fg">
179
- {renderMessageText ? renderMessageText(item.text, item) : <span className="whitespace-pre-wrap">{item.text}</span>}
179
+ <div className="max-w-[85%] min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-[15px] leading-6 text-og-fg">
180
+ {renderMessageText ? renderMessageText(item.text, item) : <Markdown>{item.text}</Markdown>}
180
181
  </div>
181
182
  </div>
182
183
  );
@@ -189,10 +190,25 @@ function AgentMessageRow({
189
190
  item: AgentMessageItem;
190
191
  renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
191
192
  }) {
193
+ const caret = item.streaming ? (
194
+ <span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden />
195
+ ) : null;
192
196
  return (
193
- <div className="animate-og-enter text-[15px] leading-7 text-og-fg">
194
- {renderMessageText ? renderMessageText(item.text, item) : <span className="whitespace-pre-wrap">{item.text}</span>}
195
- {item.streaming ? <span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden /> : null}
197
+ <div className="animate-og-enter min-w-0 text-[15px] leading-7 text-og-fg">
198
+ {renderMessageText ? (
199
+ <>
200
+ {renderMessageText(item.text, item)}
201
+ {caret}
202
+ </>
203
+ ) : (
204
+ // While streaming, let the caret ride the end of the last rendered line:
205
+ // the trailing block (usually a <p>) flows inline so the caret sits on
206
+ // its baseline instead of dropping to a new line.
207
+ <div className={item.streaming ? "[&_>div>:last-child]:inline" : undefined}>
208
+ <Markdown>{item.text}</Markdown>
209
+ {caret}
210
+ </div>
211
+ )}
196
212
  </div>
197
213
  );
198
214
  }
@@ -0,0 +1,87 @@
1
+ import type { ClientModel } from "@opengeni/sdk";
2
+ import { ChevronDownIcon } from "lucide-react";
3
+ import { useId, useMemo } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ export type ModelPickerProps = {
7
+ /** The host-exposed models to choose from (typically {@link useAvailableModels}). */
8
+ models: ClientModel[];
9
+ /** Controlled selection — the model id, or undefined for "nothing chosen yet". */
10
+ value?: string | undefined;
11
+ /** Called with the chosen model id when the operator picks a row. */
12
+ onChange: (modelId: string) => void;
13
+ disabled?: boolean | undefined;
14
+ className?: string | undefined;
15
+ };
16
+
17
+ /**
18
+ * The model picker — a compact dropdown for the composer footer, grouping the
19
+ * host-exposed models by `providerLabel` (so "OpenAI" and "Fireworks AI" head
20
+ * their own sections) and showing each model's display `label`. A native
21
+ * `<select>` keeps it keyboard- and screen-reader-accessible for free and
22
+ * themes via the package's og-* tokens; the chevron is a decorative overlay.
23
+ *
24
+ * Controlled: the host owns `value`/`onChange` and threads the selection into
25
+ * the send path. Renders nothing when no models are exposed, so a single-model
26
+ * deployment shows no chrome.
27
+ */
28
+ export function ModelPicker({ models, value, onChange, disabled, className }: ModelPickerProps) {
29
+ const selectId = useId();
30
+ // Group by provider, preserving first-seen order for both the providers and
31
+ // the models within each — the server already orders the list (default model
32
+ // and built-in provider first), so we must not re-sort it.
33
+ const groups = useMemo(() => {
34
+ const byProvider = new Map<string, { label: string; models: ClientModel[] }>();
35
+ for (const model of models) {
36
+ let group = byProvider.get(model.provider);
37
+ if (!group) {
38
+ group = { label: model.providerLabel, models: [] };
39
+ byProvider.set(model.provider, group);
40
+ }
41
+ group.models.push(model);
42
+ }
43
+ return [...byProvider.values()];
44
+ }, [models]);
45
+
46
+ if (models.length === 0) {
47
+ return null;
48
+ }
49
+
50
+ return (
51
+ <span className={cn("relative inline-flex items-center", className)}>
52
+ <label htmlFor={selectId} className="sr-only">
53
+ Model
54
+ </label>
55
+ <select
56
+ id={selectId}
57
+ value={value ?? ""}
58
+ onChange={(event) => onChange(event.target.value)}
59
+ disabled={disabled === true}
60
+ aria-label="Model"
61
+ className={cn(
62
+ // Sized like the other footer controls; the chevron overlay needs the
63
+ // right padding so the value never collides with it.
64
+ "h-8 max-w-[180px] cursor-pointer appearance-none truncate rounded-og-md bg-transparent",
65
+ "py-0 pl-2 pr-6 text-[13px] text-og-fg-muted",
66
+ "transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
67
+ "focus:outline-none focus-visible:outline-none",
68
+ "disabled:cursor-not-allowed disabled:opacity-50",
69
+ )}
70
+ >
71
+ {groups.map((group) => (
72
+ <optgroup key={group.label} label={group.label}>
73
+ {group.models.map((model) => (
74
+ <option key={model.id} value={model.id}>
75
+ {model.label}
76
+ </option>
77
+ ))}
78
+ </optgroup>
79
+ ))}
80
+ </select>
81
+ <ChevronDownIcon
82
+ aria-hidden
83
+ className="pointer-events-none absolute right-1.5 size-3.5 text-og-fg-subtle"
84
+ />
85
+ </span>
86
+ );
87
+ }
@@ -0,0 +1,39 @@
1
+ import type { ClientModel } from "@opengeni/sdk";
2
+ import { useCallback } from "react";
3
+ import { useOpenGeniClient, type ClientOverride } from "../provider";
4
+ import { usePolledValue } from "./internal";
5
+
6
+ export type UseAvailableModelsOptions = Pick<ClientOverride, "client"> & {
7
+ /** Refresh interval (ms). Off by default — the host model list rarely moves. */
8
+ pollIntervalMs?: number | undefined;
9
+ enabled?: boolean | undefined;
10
+ };
11
+
12
+ export type UseAvailableModelsResult = {
13
+ /** The provider-grouped models the host exposes (empty until loaded). */
14
+ models: ClientModel[];
15
+ /** The deployment's default model id, null until loaded. */
16
+ defaultModel: string | null;
17
+ loading: boolean;
18
+ error: Error | null;
19
+ refresh: () => Promise<void>;
20
+ };
21
+
22
+ /**
23
+ * The host-exposed model list for a <ModelPicker>: fetches the deployment's
24
+ * public client config (`GET /v1/config/client`) and surfaces the richer
25
+ * provider-grouped `models` plus the `defaultModel` the picker should preselect.
26
+ * Deployment-scoped, so it only needs the client (no workspace).
27
+ */
28
+ export function useAvailableModels(options: UseAvailableModelsOptions = {}): UseAvailableModelsResult {
29
+ const client = useOpenGeniClient(options);
30
+ const load = useCallback(async () => await client.getClientConfig(), [client]);
31
+ const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
32
+ return {
33
+ models: state.data?.models ?? [],
34
+ defaultModel: state.data?.defaultModel ?? null,
35
+ loading: state.loading,
36
+ error: state.error,
37
+ refresh: state.refresh,
38
+ };
39
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export { useWorkspaces } from "./hooks/use-workspaces";
43
43
  export type { UseWorkspacesOptions, UseWorkspacesResult } from "./hooks/use-workspaces";
44
44
  export { useBillingUsage } from "./hooks/use-billing-usage";
45
45
  export type { UseBillingUsageOptions, UseBillingUsageResult } from "./hooks/use-billing-usage";
46
+ export { useAvailableModels } from "./hooks/use-available-models";
47
+ export type { UseAvailableModelsOptions, UseAvailableModelsResult } from "./hooks/use-available-models";
46
48
 
47
49
  // Pending-approvals projection
48
50
  export { approvalsFromRequiresAction, projectPendingApprovals } from "./approvals";
@@ -103,8 +105,12 @@ export type { CommandPaletteProps } from "./components/command-palette";
103
105
  // Components
104
106
  export { ChatComposer } from "./components/chat-composer";
105
107
  export type { ChatComposerProps } from "./components/chat-composer";
108
+ export { ModelPicker } from "./components/model-picker";
109
+ export type { ModelPickerProps } from "./components/model-picker";
106
110
  export { MessageTimeline } from "./components/message-timeline";
107
111
  export type { MessageTimelineProps } from "./components/message-timeline";
112
+ export { Markdown } from "./components/markdown";
113
+ export type { MarkdownProps } from "./components/markdown";
108
114
  export { SessionStatus, StatusDot, SESSION_STATUS_META } from "./components/session-status";
109
115
  export type { SessionStatusProps, StatusDotProps, SessionStatusMeta } from "./components/session-status";
110
116
  export { FleetTile, sessionDisplayTitle } from "./components/fleet-tile";