@opengeni/react 1.3.0 → 1.4.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 (69) hide show
  1. package/README.md +21 -1
  2. package/dist/artifacts.js +2 -2
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/{chunk-ERGYJIBB.js → chunk-AWZLIMKX.js} +2 -2
  5. package/dist/{chunk-ERGYJIBB.js.map → chunk-AWZLIMKX.js.map} +1 -1
  6. package/dist/{chunk-FZCP527Y.js → chunk-CJUO7J5Z.js} +2 -2
  7. package/dist/{chunk-742CZ3L3.js → chunk-H5X55XH7.js} +16 -200
  8. package/dist/chunk-H5X55XH7.js.map +1 -0
  9. package/dist/{chunk-PGSX7UCP.js → chunk-LJ2UTJJE.js} +1369 -785
  10. package/dist/chunk-LJ2UTJJE.js.map +1 -0
  11. package/dist/{chunk-CY7WK5FD.js → chunk-LN6TCXD3.js} +22 -1
  12. package/dist/chunk-LN6TCXD3.js.map +1 -0
  13. package/dist/{chunk-TMYGL6X5.js → chunk-MBMPHOMA.js} +71 -6
  14. package/dist/chunk-MBMPHOMA.js.map +1 -0
  15. package/dist/{chunk-RC6BZ44Q.js → chunk-OA6HFRQP.js} +2 -2
  16. package/dist/{chunk-WWEUO2PL.js → chunk-WZMYFJD3.js} +190 -11
  17. package/dist/chunk-WZMYFJD3.js.map +1 -0
  18. package/dist/components/composer-transcription-control.d.ts +3 -0
  19. package/dist/components/open-geni-chat.d.ts +6 -2
  20. package/dist/components/session-conversation.d.ts +25 -19
  21. package/dist/components/session-list.d.ts +4 -1
  22. package/dist/components/tool-action-review.d.ts +29 -2
  23. package/dist/components/tool-review-history.d.ts +8 -4
  24. package/dist/composer.js +1 -1
  25. package/dist/connect.js +1 -1
  26. package/dist/hooks/use-client-config-flags.d.ts +37 -0
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +21 -11
  29. package/dist/index.js.map +1 -1
  30. package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
  31. package/dist/realtime/realtime-control.d.ts +2 -0
  32. package/dist/realtime.js +25 -4
  33. package/dist/realtime.js.map +1 -1
  34. package/dist/session-capability-policy.d.ts +20 -1
  35. package/dist/session-ui.js +6 -6
  36. package/dist/session.js +6 -6
  37. package/dist/timeline/index.d.ts +2 -0
  38. package/dist/timeline/retained-loaders.d.ts +27 -0
  39. package/package.json +3 -3
  40. package/src/components/approval-surface.tsx +190 -79
  41. package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
  42. package/src/components/composer-transcription-control.tsx +14 -0
  43. package/src/components/composer.tsx +1 -0
  44. package/src/components/open-geni-chat.tsx +53 -13
  45. package/src/components/session-conversation.tsx +229 -63
  46. package/src/components/session-list.tsx +9 -2
  47. package/src/components/tool-action-review.tsx +502 -239
  48. package/src/components/tool-review-history.tsx +18 -43
  49. package/src/hooks/use-client-config-flags.ts +102 -0
  50. package/src/hooks/use-session-events.ts +19 -16
  51. package/src/hooks/use-transcription.ts +6 -0
  52. package/src/hooks/use-voice-input.ts +6 -0
  53. package/src/index.ts +9 -0
  54. package/src/realtime/realtime-control.tsx +28 -1
  55. package/src/session-capability-policy.ts +119 -6
  56. package/src/timeline/index.ts +10 -0
  57. package/src/timeline/retained-loaders.ts +86 -0
  58. package/src/timeline/tool-display-name.ts +5 -1
  59. package/src/timeline/tool-renderers.tsx +143 -10
  60. package/styles/compiled.css +65 -21
  61. package/styles/tokens.css +1 -1
  62. package/dist/chunk-742CZ3L3.js.map +0 -1
  63. package/dist/chunk-CY7WK5FD.js.map +0 -1
  64. package/dist/chunk-PGSX7UCP.js.map +0 -1
  65. package/dist/chunk-TMYGL6X5.js.map +0 -1
  66. package/dist/chunk-WWEUO2PL.js.map +0 -1
  67. /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
  68. /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
  69. /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
@@ -1,4 +1,10 @@
1
- import type { FileResourceRef, LatencyMode, OpenGeniClient, ReasoningEffort } from "@opengeni/sdk";
1
+ import {
2
+ OpenGeniApiError,
3
+ type FileResourceRef,
4
+ type LatencyMode,
5
+ type OpenGeniClient,
6
+ type ReasoningEffort,
7
+ } from "@opengeni/sdk";
2
8
  import { MenuIcon, XIcon } from "lucide-react";
3
9
  import { useCallback, useRef, useState, type CSSProperties, type FormEvent } from "react";
4
10
  import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
@@ -14,9 +20,10 @@ import {
14
20
  import { useOpenGeni, type ClientOverride } from "../session-context";
15
21
  import { ChatComposer } from "./chat-composer";
16
22
  import { ModelPolicyPicker } from "./model-policy-picker";
23
+ import { useClientConfigFlags } from "../hooks/use-client-config-flags";
17
24
  import {
18
25
  SessionConversation,
19
- useClientConfigFlags,
26
+ useComposerTranscription,
20
27
  type SessionConversationProps,
21
28
  } from "./session-conversation";
22
29
  import { SessionList, type SessionListLabels, type SessionListProps } from "./session-list";
@@ -71,7 +78,11 @@ export type OpenGeniChatProps = ClientOverride &
71
78
  options: OpenGeniChatCreateOptions,
72
79
  ) => Promise<string>)
73
80
  | undefined;
74
- /** Hide the "New chat" entry point. */
81
+ /**
82
+ * Hide the "New chat" entry point. It is also hidden when the session proxy
83
+ * reports chat creation unavailable (no `createSession` hook) and no
84
+ * `createSession` prop is given.
85
+ */
75
86
  newChat?: boolean | undefined;
76
87
  /**
77
88
  * Forwarded to the conversation (message rendering, tool renderers,
@@ -152,6 +163,9 @@ function Chat({
152
163
  const [listRevision, setListRevision] = useState(0);
153
164
  const root = useRef<HTMLDivElement>(null);
154
165
  const hostTheme = useHostTheme(root, { theme, surface });
166
+ const config = useClientConfigFlags(context.client);
167
+ // A host creator replaces the proxy route; otherwise trust the proxy's report.
168
+ const canCreate = createSession !== undefined || config.sessionCreation;
155
169
 
156
170
  const select = useCallback(
157
171
  (next: string | null) => {
@@ -171,12 +185,20 @@ function Chat({
171
185
  if (createSession) return await createSession(initialMessage, idempotencyKey, options);
172
186
  const creator = (context.client as unknown as CreateClient).createSession;
173
187
  if (typeof creator !== "function") throw new Error(labels.newChatUnavailable);
174
- const created = await creator.call(context.client, context.workspaceId, {
175
- initialMessage,
176
- idempotencyKey,
177
- ...options,
178
- } as Parameters<NonNullable<CreateClient["createSession"]>>[1]);
179
- return created.id;
188
+ try {
189
+ const created = await creator.call(context.client, context.workspaceId, {
190
+ initialMessage,
191
+ idempotencyKey,
192
+ ...options,
193
+ } as Parameters<NonNullable<CreateClient["createSession"]>>[1]);
194
+ return created.id;
195
+ } catch (error) {
196
+ // A proxy without a createSession hook refuses the route itself.
197
+ if (error instanceof OpenGeniApiError && error.code === "route_not_allowed") {
198
+ throw new Error(labels.newChatUnavailable, { cause: error });
199
+ }
200
+ throw error;
201
+ }
180
202
  },
181
203
  [context.client, context.workspaceId, createSession, labels.newChatUnavailable],
182
204
  );
@@ -188,7 +210,7 @@ function Chat({
188
210
  labels={labels}
189
211
  selectedSessionId={selected}
190
212
  onSelect={select}
191
- onNewChat={newChat ? () => select(null) : undefined}
213
+ onNewChat={newChat && canCreate ? () => select(null) : undefined}
192
214
  onArchived={(archivedId) => {
193
215
  if (archivedId === selected) select(null);
194
216
  }}
@@ -256,6 +278,8 @@ function Chat({
256
278
  {selected ? (
257
279
  <SessionConversation
258
280
  {...conversationProps}
281
+ // Sub-agent chats open in place, like a chat from the list.
282
+ onOpenSession={conversationProps?.onOpenSession ?? select}
259
283
  {...scope}
260
284
  sessionId={selected}
261
285
  height="100%"
@@ -266,6 +290,7 @@ function Chat({
266
290
  labels={labels}
267
291
  placeholderOverride={labelOverrides?.newChatPlaceholder}
268
292
  conversationProps={conversationProps}
293
+ available={canCreate}
269
294
  create={create}
270
295
  onCreated={(id) => {
271
296
  setListRevision((revision) => revision + 1);
@@ -291,6 +316,7 @@ function NewChat({
291
316
  labels,
292
317
  placeholderOverride,
293
318
  conversationProps,
319
+ available,
294
320
  create,
295
321
  onCreated,
296
322
  }: {
@@ -298,6 +324,8 @@ function NewChat({
298
324
  labels: OpenGeniChatLabels;
299
325
  placeholderOverride: string | undefined;
300
326
  conversationProps: OpenGeniChatProps["conversationProps"];
327
+ /** False when this product cannot start chats; the composer is disabled and says so. */
328
+ available: boolean;
301
329
  create: (
302
330
  initialMessage: string,
303
331
  idempotencyKey: string,
@@ -317,6 +345,11 @@ function NewChat({
317
345
  });
318
346
  const files = useFileAttachments(scope);
319
347
  const uploadsEnabled = (conversationProps?.attachments ?? true) && config.uploads;
348
+ const transcription = useComposerTranscription(
349
+ context.client,
350
+ context.workspaceId,
351
+ (conversationProps?.voiceInput ?? true) && available ? config.voiceInput : null,
352
+ );
320
353
  const [text, setText] = useState("");
321
354
  const [sending, setSending] = useState(false);
322
355
  const [error, setError] = useState<{ cause: unknown } | null>(null);
@@ -333,7 +366,7 @@ function NewChat({
333
366
 
334
367
  const submit = async (): Promise<boolean> => {
335
368
  const initialMessage = text.trim() || (resources.length > 0 ? FILE_ONLY_MESSAGE_TEXT : "");
336
- if (!initialMessage || sending || pendingFiles) return false;
369
+ if (!initialMessage || sending || pendingFiles || !available) return false;
337
370
  const options: OpenGeniChatCreateOptions = {
338
371
  ...(resources.length > 0 ? { resources } : {}),
339
372
  ...(showModelPicker ? choice : {}),
@@ -377,7 +410,8 @@ function NewChat({
377
410
  send: submit,
378
411
  steer: submit,
379
412
  sending,
380
- canSend: (text.trim().length > 0 || resources.length > 0) && !sending && !pendingFiles,
413
+ canSend:
414
+ available && (text.trim().length > 0 || resources.length > 0) && !sending && !pendingFiles,
381
415
  pause: NOOP_ASYNC,
382
416
  pausing: false,
383
417
  resume: NOOP_ASYNC,
@@ -420,8 +454,10 @@ function NewChat({
420
454
  ) : null}
421
455
  <ChatComposer
422
456
  {...composerProps}
457
+ {...(transcription && composerProps?.transcription === undefined ? { transcription } : {})}
423
458
  composer={composer}
424
459
  attachments={uploadsEnabled ? files : undefined}
460
+ disabled={!available || composerProps?.disabled}
425
461
  runControl="none"
426
462
  running={false}
427
463
  placeholder={placeholderOverride ?? composerProps?.placeholder ?? labels.newChatPlaceholder}
@@ -455,7 +491,11 @@ function NewChat({
455
491
  }
456
492
  responsiveBasis={composerProps?.responsiveBasis ?? "container"}
457
493
  />
458
- {error ? (
494
+ {!available ? (
495
+ <p className="text-center text-og-sm text-og-fg-muted" data-og-new-chat-unavailable="">
496
+ {labels.newChatUnavailable}
497
+ </p>
498
+ ) : error ? (
459
499
  <p role="alert" className="text-center text-og-sm text-og-status-failed">
460
500
  {formatError(
461
501
  error.cause,
@@ -1,4 +1,10 @@
1
- import { SESSION_SCOPE_HEADER, type ReasoningEffort, type SendMessageInput } from "@opengeni/sdk";
1
+ import {
2
+ resolveWorkspaceVoiceInputEnabled,
3
+ SESSION_SCOPE_HEADER,
4
+ type ClientVoiceInputConfig,
5
+ type OpenGeniClient,
6
+ type SendMessageInput,
7
+ } from "@opengeni/sdk";
2
8
  import {
3
9
  lazy,
4
10
  Suspense,
@@ -7,6 +13,7 @@ import {
7
13
  useMemo,
8
14
  useRef,
9
15
  useState,
16
+ type ComponentProps,
10
17
  type CSSProperties,
11
18
  } from "react";
12
19
  import type { SiteSnapshotClient } from "./artifacts/chat-interactive-block";
@@ -20,9 +27,17 @@ import { useComposer } from "../hooks/use-composer";
20
27
  import { useHumanInputRequests } from "../hooks/use-human-input";
21
28
  import { useFileAttachments } from "../hooks/use-file-attachments";
22
29
  import { useSessionControl } from "../hooks/use-session-control";
30
+ import { useGoal, type UseGoalOptions } from "../hooks/use-goal";
31
+ import { useClientConfigFlags } from "../hooks/use-client-config-flags";
32
+ import {
33
+ createSessionRetainedScreenshotLoader,
34
+ createWorkspaceRetainedArtifactLoader,
35
+ createWorkspaceRetainedVideoLoader,
36
+ } from "../timeline/retained-loaders";
23
37
  import { projectPendingApprovals } from "../approvals";
24
38
  import { ApprovalSurface } from "./approval-surface";
25
39
  import { ChatComposer, type ChatComposerProps } from "./chat-composer";
40
+ import type { ComposerTranscriptionControlProps } from "./composer-transcription-control";
26
41
  import { SessionChrome } from "./session-chrome";
27
42
  import { HumanInputSurface, type HumanInputSurfaceProps } from "./human-input-surface";
28
43
  import { MessageTimeline, type MessageTimelineProps } from "./message-timeline";
@@ -50,11 +65,14 @@ export type SessionConversationLabels = {
50
65
  retry: string;
51
66
  /** Action when the conversation could not load at all. */
52
67
  tryAgain: string;
68
+ /** Shown in place of a Site preview when the session proxy does not serve Sites. */
69
+ sitePreviewUnavailable: string;
53
70
  };
54
71
 
55
72
  const DEFAULT_CONVERSATION_LABELS: SessionConversationLabels = {
56
73
  retry: "Retry",
57
74
  tryAgain: "Try again",
75
+ sitePreviewUnavailable: "Site preview unavailable",
58
76
  };
59
77
 
60
78
  export type SessionConversationProps = ClientOverride &
@@ -75,10 +93,17 @@ export type SessionConversationProps = ClientOverride &
75
93
  * after `resolveLink`.
76
94
  */
77
95
  onOpenArtifact?: ((target: OpenGeniViewerTarget) => void) | undefined;
96
+ /**
97
+ * Open another session the conversation points at: a sub-agent's chat from
98
+ * its worker card or a child update. `OpenGeniChat` opens it in place;
99
+ * without it those entries are not links.
100
+ */
101
+ onOpenSession?: ((sessionId: string) => void) | undefined;
78
102
  /**
79
103
  * Inline previews for assistant `opengeni-site` / `opengeni-html` fences.
80
- * Defaults to the OpenGeni preview (Site reads need the proxy's
81
- * `artifacts` option); `false` shows the fence as code.
104
+ * Defaults to the OpenGeni preview. Behind a session proxy without its
105
+ * `artifacts` option, Sites show as unavailable without a request; `false`
106
+ * shows the fence as code.
82
107
  */
83
108
  renderInteractiveBlock?: MessageTimelineProps["renderInteractiveBlock"] | false;
84
109
  /** Product-specific tool-call renderers; defaults to the built-in registry. */
@@ -95,6 +120,12 @@ export type SessionConversationProps = ClientOverride &
95
120
  * appears only when the deployment's client config enables file uploads.
96
121
  */
97
122
  attachments?: boolean | undefined;
123
+ /**
124
+ * Composer microphone (dictation into the draft). Defaults to true; it
125
+ * appears only when the client config reports voice input available
126
+ * (`createSessionProxyHandler({ voiceInput: false })` turns it off).
127
+ */
128
+ voiceInput?: boolean | undefined;
98
129
  /**
99
130
  * Show the model/reasoning picker. End users of an embedded product rarely
100
131
  * choose models, so it is hidden unless this is `true` or the client config
@@ -172,11 +203,13 @@ function Conversation({
172
203
  renderMessageText,
173
204
  resolveLink,
174
205
  onOpenArtifact,
206
+ onOpenSession,
175
207
  renderInteractiveBlock,
176
208
  toolRegistry,
177
209
  renderAllowanceExhausted,
178
210
  allowanceExhaustedLabels,
179
211
  attachments: attachmentsRequested = true,
212
+ voiceInput: voiceInputRequested = true,
180
213
  modelPicker,
181
214
  modelPickerProps,
182
215
  userMessageDisclosureLabels,
@@ -238,6 +271,14 @@ function Conversation({
238
271
  context.client,
239
272
  context.workspaceId,
240
273
  sessionId,
274
+ config.artifacts,
275
+ labelOverrides?.sitePreviewUnavailable ?? DEFAULT_CONVERSATION_LABELS.sitePreviewUnavailable,
276
+ );
277
+ const retainedLoaders = useRetainedLoaders(context.client, context.workspaceId, sessionId);
278
+ const transcription = useComposerTranscription(
279
+ context.client,
280
+ context.workspaceId,
281
+ voiceInputRequested && !importedArchive ? config.voiceInput : null,
241
282
  );
242
283
  const inheritedLinks = useOpenGeniLinkResolver();
243
284
  const defaultLinks = useMemo(
@@ -336,6 +377,8 @@ function Conversation({
336
377
  // Isolated and clipped: floating navigation stays inside the timeline.
337
378
  className="isolate min-h-0 flex-1 overflow-hidden"
338
379
  {...(toolRegistry ? { toolRegistry } : {})}
380
+ {...retainedLoaders}
381
+ {...(onOpenSession ? { onOpenSession } : {})}
339
382
  events={feed.events}
340
383
  items={conversationTimeline(feed.timeline, queue, composer)}
341
384
  turnSummary={{ rolling: true }}
@@ -394,18 +437,24 @@ function Conversation({
394
437
  autoFocus={false}
395
438
  decisionButtons
396
439
  />
397
- {terminal ? (
398
- <SessionChrome queue={queue} sessionStatus={status} readOnly />
399
- ) : (
400
- <SessionChrome
401
- queue={queue}
402
- composer={composer}
403
- sessionStatus={status}
404
- onComposerFocus={() =>
405
- region.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus()
406
- }
407
- />
408
- )}
440
+ <ConversationChrome
441
+ sessionId={sessionId}
442
+ client={context.client}
443
+ workspaceId={context.workspaceId}
444
+ events={feed.events}
445
+ chrome={
446
+ terminal
447
+ ? { queue, sessionStatus: status, readOnly: true, onOpenSession }
448
+ : {
449
+ queue,
450
+ composer,
451
+ sessionStatus: status,
452
+ onOpenSession,
453
+ onComposerFocus: () =>
454
+ region.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus(),
455
+ }
456
+ }
457
+ />
409
458
  </div>
410
459
  <div
411
460
  className="relative z-20 mx-auto w-full max-w-3xl shrink-0"
@@ -415,6 +464,9 @@ function Conversation({
415
464
  runControl="stop"
416
465
  running={running}
417
466
  {...composerProps}
467
+ {...(transcription && composerProps?.transcription === undefined
468
+ ? { transcription }
469
+ : {})}
418
470
  composer={composer}
419
471
  attachments={uploadsEnabled ? files : undefined}
420
472
  disabled={terminal || composerProps?.disabled}
@@ -456,34 +508,60 @@ function Conversation({
456
508
  );
457
509
  }
458
510
 
511
+ type ConversationChromeProps = {
512
+ sessionId: string;
513
+ client: unknown;
514
+ workspaceId: string;
515
+ events: UseGoalOptions["events"];
516
+ chrome: Omit<ComponentProps<typeof SessionChrome>, "goal">;
517
+ };
518
+
519
+ /** Session chrome with the goal pill and its Pause/Resume/Clear when the client can reach goals. */
520
+ function ConversationChrome(props: ConversationChromeProps) {
521
+ const goals = props.client as Partial<Record<"getGoal" | "updateGoal" | "deleteGoal", unknown>>;
522
+ const canReachGoals =
523
+ typeof goals.getGoal === "function" &&
524
+ typeof goals.updateGoal === "function" &&
525
+ typeof goals.deleteGoal === "function";
526
+ return canReachGoals ? <GoalChrome {...props} /> : <SessionChrome {...props.chrome} />;
527
+ }
528
+
529
+ function GoalChrome({ sessionId, client, workspaceId, events, chrome }: ConversationChromeProps) {
530
+ const goal = useGoal(sessionId, {
531
+ client: client as UseGoalOptions["client"],
532
+ workspaceId,
533
+ events,
534
+ });
535
+ return <SessionChrome {...chrome} goal={goal} />;
536
+ }
537
+
459
538
  const LazyChatInteractiveBlock = lazy(() =>
460
539
  import("./artifacts/chat-interactive-block").then((module) => ({
461
540
  default: module.ChatInteractiveBlock,
462
541
  })),
463
542
  );
464
543
 
465
- type ScopableClient = SiteSnapshotClient & {
466
- withHeaders?: (headers: Readonly<Record<string, string>>) => SiteSnapshotClient;
467
- };
544
+ /** This client with the conversation's session scope header, when it can add headers. */
545
+ function sessionScoped<T>(client: T, sessionId: string): T {
546
+ const candidate = client as { withHeaders?: (headers: Readonly<Record<string, string>>) => T };
547
+ return typeof candidate.withHeaders === "function"
548
+ ? candidate.withHeaders({ [SESSION_SCOPE_HEADER]: sessionId })
549
+ : client;
550
+ }
468
551
 
469
552
  /** Inline Site/HTML preview reading through this conversation's session scope. */
470
553
  function useDefaultInteractiveBlock(
471
554
  client: unknown,
472
555
  workspaceId: string,
473
556
  sessionId: string,
557
+ sitesAvailable: boolean,
558
+ sitePreviewUnavailable: string,
474
559
  ): NonNullable<MessageTimelineProps["renderInteractiveBlock"]> {
475
560
  // Scope lazily: only a rendered preview reads, and some clients (a Site's
476
561
  // own client) cannot add headers.
477
562
  const scoped = useMemo((): SiteSnapshotClient => {
478
563
  let resolved: SiteSnapshotClient | null = null;
479
- const get = () => {
480
- const candidate = client as ScopableClient;
481
- resolved ??=
482
- typeof candidate.withHeaders === "function"
483
- ? candidate.withHeaders({ [SESSION_SCOPE_HEADER]: sessionId })
484
- : candidate;
485
- return resolved;
486
- };
564
+ const get = () => (resolved ??= sessionScoped(client as SiteSnapshotClient, sessionId));
487
565
  return {
488
566
  getWorkspaceArtifact: (...args) => get().getWorkspaceArtifact(...args),
489
567
  getWorkspaceArtifactHtml: (...args) => get().getWorkspaceArtifactHtml(...args),
@@ -495,54 +573,142 @@ function useDefaultInteractiveBlock(
495
573
  };
496
574
  }, [client, sessionId]);
497
575
  return useCallback(
498
- (block) => (
499
- <Suspense fallback={<span role="status">Loading preview…</span>}>
500
- <LazyChatInteractiveBlock workspaceId={workspaceId} client={scoped} {...block} />
501
- </Suspense>
502
- ),
503
- [scoped, workspaceId],
576
+ (block) =>
577
+ block.kind === "site" && !sitesAvailable ? (
578
+ // The proxy does not serve Sites: say so instead of offering a load
579
+ // that can only fail.
580
+ <div
581
+ className="rounded-og-md border border-og-border bg-og-surface-1 px-3 py-2 text-og-sm text-og-fg-muted"
582
+ data-og-site-preview-unavailable=""
583
+ >
584
+ {sitePreviewUnavailable}
585
+ </div>
586
+ ) : (
587
+ <Suspense fallback={<span role="status">Loading preview…</span>}>
588
+ <LazyChatInteractiveBlock workspaceId={workspaceId} client={scoped} {...block} />
589
+ </Suspense>
590
+ ),
591
+ [scoped, workspaceId, sitesAvailable, sitePreviewUnavailable],
504
592
  );
505
593
  }
506
594
 
507
- /** Deployment/proxy flags from the client config: uploads, and whether model choice is open. */
508
- export function useClientConfigFlags(client: {
509
- getClientConfig: () => Promise<{
510
- fileUploads?: { enabled?: boolean };
511
- modelSelection?: boolean | undefined;
512
- sandboxFiles?: boolean | undefined;
513
- defaultReasoningEffort?: ReasoningEffort | undefined;
514
- }>;
515
- }): {
516
- uploads: boolean;
517
- modelSelection: boolean;
518
- sandboxFiles: boolean;
519
- defaultReasoningEffort: ReasoningEffort | null;
520
- } {
521
- const [flags, setFlags] = useState<ReturnType<typeof useClientConfigFlags>>({
522
- uploads: false,
523
- modelSelection: false,
524
- sandboxFiles: false,
525
- defaultReasoningEffort: null,
526
- });
595
+ type RetainedLoaderClient = Partial<
596
+ Pick<
597
+ OpenGeniClient,
598
+ | "createRetainedArtifactDownloadUrl"
599
+ | "downloadRetainedArtifact"
600
+ | "downloadRetainedScreenshot"
601
+ | "createVideoArtifactPlaybackSource"
602
+ >
603
+ >;
604
+
605
+ /**
606
+ * Generated images, published files, screenshots and generated video, read
607
+ * through this conversation's session scope (a session proxy only serves
608
+ * media that session produced). Clients without the SDK reads get none.
609
+ */
610
+ function useRetainedLoaders(
611
+ client: unknown,
612
+ workspaceId: string,
613
+ sessionId: string,
614
+ ): Pick<
615
+ MessageTimelineProps,
616
+ "loadRetainedArtifact" | "loadRetainedScreenshot" | "loadVideoArtifactPlayback"
617
+ > {
618
+ return useMemo(() => {
619
+ const base = client as RetainedLoaderClient;
620
+ // Scope lazily: only a rendered receipt reads.
621
+ let scoped: RetainedLoaderClient | null = null;
622
+ const get = () => (scoped ??= sessionScoped(base, sessionId));
623
+ return {
624
+ ...(typeof base.downloadRetainedArtifact === "function" &&
625
+ typeof base.createRetainedArtifactDownloadUrl === "function"
626
+ ? {
627
+ loadRetainedArtifact: (artifact, signal) =>
628
+ createWorkspaceRetainedArtifactLoader(
629
+ get() as Required<RetainedLoaderClient>,
630
+ workspaceId,
631
+ )(artifact, signal),
632
+ }
633
+ : {}),
634
+ ...(typeof base.downloadRetainedScreenshot === "function"
635
+ ? {
636
+ loadRetainedScreenshot: (artifact, signal) =>
637
+ createSessionRetainedScreenshotLoader(
638
+ get() as Required<RetainedLoaderClient>,
639
+ workspaceId,
640
+ sessionId,
641
+ )(artifact, signal),
642
+ }
643
+ : {}),
644
+ ...(typeof base.createVideoArtifactPlaybackSource === "function"
645
+ ? {
646
+ loadVideoArtifactPlayback: (artifactId, signal) =>
647
+ createWorkspaceRetainedVideoLoader(
648
+ get() as Required<RetainedLoaderClient>,
649
+ workspaceId,
650
+ )(artifactId, signal),
651
+ }
652
+ : {}),
653
+ } satisfies Pick<
654
+ MessageTimelineProps,
655
+ "loadRetainedArtifact" | "loadRetainedScreenshot" | "loadVideoArtifactPlayback"
656
+ >;
657
+ }, [client, workspaceId, sessionId]);
658
+ }
659
+
660
+ /**
661
+ * The composer microphone: shown when the deployment can transcribe, enabled
662
+ * once the workspace's voice-input setting allows it.
663
+ */
664
+ export function useComposerTranscription(
665
+ client: unknown,
666
+ workspaceId: string,
667
+ capability: ClientVoiceInputConfig | null,
668
+ ): ComposerTranscriptionControlProps | null {
669
+ const [workspaceEnabled, setWorkspaceEnabled] = useState<{
670
+ key: string;
671
+ enabled: boolean;
672
+ } | null>(null);
673
+ const reader = client as Partial<Pick<OpenGeniClient, "getWorkspace" | "transcribeAudio">>;
674
+ const supported = capability !== null && typeof reader.transcribeAudio === "function";
527
675
  useEffect(() => {
676
+ if (!supported) return;
528
677
  let live = true;
529
- client.getClientConfig().then(
530
- (config) => {
678
+ const key = workspaceId;
679
+ if (typeof reader.getWorkspace !== "function") {
680
+ setWorkspaceEnabled({ key, enabled: true });
681
+ return;
682
+ }
683
+ reader.getWorkspace(workspaceId).then(
684
+ (workspace) => {
531
685
  if (live) {
532
- setFlags({
533
- uploads: config.fileUploads?.enabled === true,
534
- // Only an explicit offer shows end users the picker.
535
- modelSelection: config.modelSelection === true,
536
- sandboxFiles: config.sandboxFiles !== false,
537
- defaultReasoningEffort: config.defaultReasoningEffort ?? null,
686
+ setWorkspaceEnabled({
687
+ key,
688
+ enabled: resolveWorkspaceVoiceInputEnabled(workspace.settings) ?? true,
538
689
  });
539
690
  }
540
691
  },
541
- () => undefined,
692
+ // The transcription request still enforces the setting.
693
+ () => live && setWorkspaceEnabled({ key, enabled: true }),
542
694
  );
543
695
  return () => {
544
696
  live = false;
545
697
  };
546
- }, [client]);
547
- return flags;
698
+ }, [reader, workspaceId, supported]);
699
+ return useMemo(
700
+ () =>
701
+ supported
702
+ ? {
703
+ client: reader as Pick<OpenGeniClient, "transcribeAudio">,
704
+ workspaceId,
705
+ capability,
706
+ workspaceEnabled:
707
+ workspaceEnabled?.key === workspaceId ? workspaceEnabled.enabled : false,
708
+ }
709
+ : null,
710
+ [supported, reader, workspaceId, capability, workspaceEnabled],
711
+ );
548
712
  }
713
+
714
+ export { useClientConfigFlags } from "../hooks/use-client-config-flags";
@@ -1,6 +1,7 @@
1
1
  import { deriveSessionDisplayTitle, type OpenGeniClient, type Session } from "@opengeni/sdk";
2
2
  import { ArchiveIcon, PencilIcon, PlusIcon } from "lucide-react";
3
3
  import { useEffect, useRef, useState, type FormEvent } from "react";
4
+ import { useClientConfigFlags } from "../hooks/use-client-config-flags";
4
5
  import { useWorkspaceSessions } from "../hooks/use-workspace-sessions";
5
6
  import { cn } from "../lib/cn";
6
7
  import { formatRelativeTime } from "../lib/format";
@@ -38,7 +39,10 @@ export type SessionListProps = ClientOverride & {
38
39
  onNewChat?: (() => void) | undefined;
39
40
  /** Allow inline rename (`updateSession`). Defaults to true. */
40
41
  rename?: boolean | undefined;
41
- /** Allow archiving (`updateSessionArchive`). Defaults to true. */
42
+ /**
43
+ * Allow archiving (`updateSessionArchive`). Defaults to true; hidden when the
44
+ * session proxy reports archive disabled (`archive: false`).
45
+ */
42
46
  archive?: boolean | undefined;
43
47
  /** Called after the selected chat was archived, so the host can move on. */
44
48
  onArchived?: ((sessionId: string) => void) | undefined;
@@ -94,7 +98,10 @@ export function SessionList({
94
98
  const [error, setError] = useState<{ cause: unknown } | null>(null);
95
99
  const formatError = useErrorMessage();
96
100
  const archiveClient = context.client as unknown as ArchiveClient;
97
- const canArchive = archive && typeof archiveClient.updateSessionArchive === "function";
101
+ // A session proxy with `archive: false` reports it; hide what would fail.
102
+ const config = useClientConfigFlags(context.client as never);
103
+ const canArchive =
104
+ archive && config.archive && typeof archiveClient.updateSessionArchive === "function";
98
105
 
99
106
  const run = async (id: string, action: () => Promise<unknown>) => {
100
107
  setBusy(id);