@opengeni/react 1.2.1-canary.37222965366001 → 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 (75) hide show
  1. package/README.md +52 -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-4OTO7KBK.js → chunk-LJ2UTJJE.js} +2737 -1580
  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-X34BSY2R.js → chunk-WZMYFJD3.js} +242 -63
  17. package/dist/chunk-WZMYFJD3.js.map +1 -0
  18. package/dist/components/approval-surface.d.ts +11 -13
  19. package/dist/components/composer-transcription-control.d.ts +3 -0
  20. package/dist/components/open-geni-chat.d.ts +26 -7
  21. package/dist/components/session-conversation.d.ts +26 -3
  22. package/dist/components/session-list.d.ts +4 -1
  23. package/dist/components/session-proxy-scope.d.ts +16 -2
  24. package/dist/components/tool-action-review.d.ts +47 -0
  25. package/dist/components/tool-review-history.d.ts +25 -0
  26. package/dist/composer.js +1 -1
  27. package/dist/connect.js +1 -1
  28. package/dist/hooks/use-client-config-flags.d.ts +37 -0
  29. package/dist/index.d.ts +7 -4
  30. package/dist/index.js +27 -11
  31. package/dist/index.js.map +1 -1
  32. package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
  33. package/dist/realtime/realtime-control.d.ts +2 -0
  34. package/dist/realtime.js +25 -4
  35. package/dist/realtime.js.map +1 -1
  36. package/dist/session-capability-policy.d.ts +20 -1
  37. package/dist/session-ui.d.ts +4 -1
  38. package/dist/session-ui.js +12 -6
  39. package/dist/session-ui.js.map +1 -1
  40. package/dist/session.js +6 -6
  41. package/dist/timeline/index.d.ts +2 -0
  42. package/dist/timeline/retained-loaders.d.ts +27 -0
  43. package/package.json +3 -3
  44. package/src/components/approval-surface.tsx +368 -239
  45. package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
  46. package/src/components/composer-transcription-control.tsx +14 -0
  47. package/src/components/composer.tsx +1 -0
  48. package/src/components/open-geni-chat.tsx +245 -72
  49. package/src/components/session-conversation.tsx +243 -58
  50. package/src/components/session-list.tsx +9 -2
  51. package/src/components/session-proxy-scope.tsx +47 -7
  52. package/src/components/tool-action-review.tsx +649 -0
  53. package/src/components/tool-review-history.tsx +88 -0
  54. package/src/hooks/use-client-config-flags.ts +102 -0
  55. package/src/hooks/use-session-events.ts +19 -16
  56. package/src/hooks/use-transcription.ts +6 -0
  57. package/src/hooks/use-voice-input.ts +6 -0
  58. package/src/index.ts +18 -1
  59. package/src/realtime/realtime-control.tsx +28 -1
  60. package/src/session-capability-policy.ts +119 -6
  61. package/src/session-ui.ts +9 -1
  62. package/src/timeline/index.ts +10 -0
  63. package/src/timeline/retained-loaders.ts +86 -0
  64. package/src/timeline/tool-display-name.ts +5 -1
  65. package/src/timeline/tool-renderers.tsx +148 -8
  66. package/styles/compiled.css +79 -15
  67. package/styles/tokens.css +1 -1
  68. package/dist/chunk-4OTO7KBK.js.map +0 -1
  69. package/dist/chunk-742CZ3L3.js.map +0 -1
  70. package/dist/chunk-CY7WK5FD.js.map +0 -1
  71. package/dist/chunk-TMYGL6X5.js.map +0 -1
  72. package/dist/chunk-X34BSY2R.js.map +0 -1
  73. /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
  74. /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
  75. /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
@@ -203,7 +203,7 @@ function EditableBody({
203
203
  workspaceId: string;
204
204
  sessionId: string;
205
205
  result:
206
- | { kind: "ready"; config: NonNullable<ClientConfig["artifacts"]> }
206
+ | { kind: "ready"; config: Exclude<NonNullable<ClientConfig["artifacts"]>, false> }
207
207
  | { kind: "unavailable" }
208
208
  | { kind: "error"; error: unknown };
209
209
  } | null>(null);
@@ -359,7 +359,9 @@ function EditableBody({
359
359
  );
360
360
  }
361
361
 
362
- function artifactCapabilityKey(config: NonNullable<ClientConfig["artifacts"]>): string {
362
+ function artifactCapabilityKey(
363
+ config: Exclude<NonNullable<ClientConfig["artifacts"]>, false>,
364
+ ): string {
363
365
  return JSON.stringify([
364
366
  config.editableLiveUrl,
365
367
  config.cachePartition.accountId,
@@ -50,6 +50,9 @@ export type ComposerTranscriptionMessages = {
50
50
  errorPermissionDenied: string;
51
51
  errorNotSupported: string;
52
52
  errorUnavailable: string;
53
+ errorInsufficientCredits: string;
54
+ errorAllowanceExhausted: string;
55
+ errorPolicyBlocked: string;
53
56
  errorTooLarge: string;
54
57
  errorInvalidAudio: string;
55
58
  errorStorageUnavailable: string;
@@ -78,6 +81,10 @@ const defaultMessages: ComposerTranscriptionMessages = {
78
81
  errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
79
82
  errorNotSupported: "Voice input is not supported on this device.",
80
83
  errorUnavailable: "Voice input is not configured.",
84
+ errorInsufficientCredits:
85
+ "Voice input needs Opengeni credits. Add credits, then retry your saved recording.",
86
+ errorAllowanceExhausted: "Your usage limit was reached. Your recording is saved for later.",
87
+ errorPolicyBlocked: "Voice input isn't allowed for this account or workspace.",
81
88
  errorTooLarge: "Recording is too large. Try a shorter message.",
82
89
  errorInvalidAudio: "The recording could not be read. Try again.",
83
90
  errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
@@ -518,6 +525,13 @@ function restingLevels(count: number): number[] {
518
525
 
519
526
  function transcriptionErrorMessage(code: string, messages: ComposerTranscriptionMessages): string {
520
527
  switch (code) {
528
+ case "insufficient_credits":
529
+ return messages.errorInsufficientCredits;
530
+ case "allowance_exhausted":
531
+ case "monthly_model_cost_limit":
532
+ return messages.errorAllowanceExhausted;
533
+ case "policy_blocked":
534
+ return messages.errorPolicyBlocked;
521
535
  case "permission_denied":
522
536
  return messages.errorPermissionDenied;
523
537
  case "not_supported":
@@ -967,6 +967,7 @@ export const Surface = forwardRef<HTMLDivElement, ComposerSurfaceProps>(function
967
967
  const controller = useComposerController();
968
968
  return (
969
969
  <div
970
+ data-og-composer-surface=""
970
971
  {...props}
971
972
  ref={ref}
972
973
  onDragOver={controller.attachments ? controller.handleDragOver : undefined}
@@ -1,6 +1,15 @@
1
- import type { OpenGeniClient } from "@opengeni/sdk";
2
- import { ArrowUpIcon, LoaderCircleIcon, MenuIcon, XIcon } from "lucide-react";
1
+ import {
2
+ OpenGeniApiError,
3
+ type FileResourceRef,
4
+ type LatencyMode,
5
+ type OpenGeniClient,
6
+ type ReasoningEffort,
7
+ } from "@opengeni/sdk";
8
+ import { MenuIcon, XIcon } from "lucide-react";
3
9
  import { useCallback, useRef, useState, type CSSProperties, type FormEvent } from "react";
10
+ import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
11
+ import { FILE_ONLY_MESSAGE_TEXT, type ComposerState } from "../hooks/use-composer";
12
+ import { useFileAttachments } from "../hooks/use-file-attachments";
4
13
  import { cn } from "../lib/cn";
5
14
  import { useErrorMessage } from "../lib/error-message";
6
15
  import {
@@ -9,7 +18,14 @@ import {
9
18
  type HostThemePreference,
10
19
  } from "../lib/host-theme";
11
20
  import { useOpenGeni, type ClientOverride } from "../session-context";
12
- import { SessionConversation, type SessionConversationProps } from "./session-conversation";
21
+ import { ChatComposer } from "./chat-composer";
22
+ import { ModelPolicyPicker } from "./model-policy-picker";
23
+ import { useClientConfigFlags } from "../hooks/use-client-config-flags";
24
+ import {
25
+ SessionConversation,
26
+ useComposerTranscription,
27
+ type SessionConversationProps,
28
+ } from "./session-conversation";
13
29
  import { SessionList, type SessionListLabels, type SessionListProps } from "./session-list";
14
30
  import { SessionProxyScope, type SessionProxyBaseUrl } from "./session-proxy-scope";
15
31
 
@@ -32,6 +48,16 @@ const DEFAULT_LABELS: Omit<OpenGeniChatLabels, keyof SessionListLabels> = {
32
48
  newChatUnavailable: "New chats are not enabled for this product.",
33
49
  };
34
50
 
51
+ /** What the first message carries besides its text. */
52
+ export type OpenGeniChatCreateOptions = {
53
+ /** Files attached in the new-chat composer. */
54
+ resources?: FileResourceRef[] | undefined;
55
+ /** Present only when the user changed it in the model picker. */
56
+ model?: string | undefined;
57
+ reasoningEffort?: ReasoningEffort | undefined;
58
+ latencyMode?: LatencyMode | undefined;
59
+ };
60
+
35
61
  export type OpenGeniChatProps = ClientOverride &
36
62
  SessionProxyBaseUrl & {
37
63
  /** Controlled selection. `null` shows the new-chat composer. */
@@ -41,18 +67,31 @@ export type OpenGeniChatProps = ClientOverride &
41
67
  onSessionChange?: ((sessionId: string | null) => void) | undefined;
42
68
  /**
43
69
  * Create a chat from its first message. Defaults to `client.createSession`
44
- * with `{ initialMessage, idempotencyKey }`, which `createSessionProxyHandler`
45
- * accepts when the server supplies a `createSession` hook. Return the new id.
70
+ * with `{ initialMessage, idempotencyKey }` plus the attached files and any
71
+ * model choice, which `createSessionProxyHandler` accepts when the server
72
+ * supplies a `createSession` hook. Return the new id.
46
73
  */
47
74
  createSession?:
48
- | ((initialMessage: string, idempotencyKey: string) => Promise<string>)
75
+ | ((
76
+ initialMessage: string,
77
+ idempotencyKey: string,
78
+ options: OpenGeniChatCreateOptions,
79
+ ) => Promise<string>)
49
80
  | undefined;
50
- /** 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
+ */
51
86
  newChat?: boolean | undefined;
52
- /** Forwarded to the conversation (message rendering, tool renderers, composer...). */
87
+ /**
88
+ * Forwarded to the conversation (message rendering, tool renderers,
89
+ * composer...). `attachments`, `modelPicker`, `modelPickerProps`, and
90
+ * `composerProps` also apply to the new-chat composer.
91
+ */
53
92
  conversationProps?: Omit<
54
93
  SessionConversationProps,
55
- "sessionId" | "client" | "workspaceId" | "baseUrl"
94
+ "sessionId" | "client" | "workspaceId" | "baseUrl" | "headers" | "fetch"
56
95
  >;
57
96
  /** Forwarded to the list (rename/archive toggles, page size). */
58
97
  listProps?: Pick<SessionListProps, "rename" | "archive" | "pageSize"> | undefined;
@@ -83,11 +122,17 @@ type CreateClient = Partial<Pick<OpenGeniClient, "createSession">>;
83
122
  * `<OpenGeniChat baseUrl="/api/opengeni" />` needs no provider: it talks to
84
123
  * your session proxy and uses the workspace the proxy resolves.
85
124
  */
86
- export function OpenGeniChat({ baseUrl, ...props }: OpenGeniChatProps) {
125
+ export function OpenGeniChat({ baseUrl, headers, fetch, ...props }: OpenGeniChatProps) {
87
126
  if (baseUrl === undefined) return <Chat {...props} />;
88
- const { client: _client, workspaceId, ...rest } = props;
127
+ const { client, workspaceId, ...rest } = props;
89
128
  return (
90
- <SessionProxyScope baseUrl={baseUrl} workspaceId={workspaceId}>
129
+ <SessionProxyScope
130
+ baseUrl={baseUrl}
131
+ workspaceId={workspaceId}
132
+ client={client}
133
+ headers={headers}
134
+ fetch={fetch}
135
+ >
91
136
  <Chat {...rest} />
92
137
  </SessionProxyScope>
93
138
  );
@@ -108,7 +153,7 @@ function Chat({
108
153
  height = "100%",
109
154
  theme,
110
155
  surface,
111
- }: Omit<OpenGeniChatProps, "baseUrl">) {
156
+ }: Omit<OpenGeniChatProps, "baseUrl" | "headers" | "fetch">) {
112
157
  const labels = { ...DEFAULT_LABELS, ...labelOverrides } as OpenGeniChatLabels;
113
158
  const scope = { client, workspaceId };
114
159
  const context = useOpenGeni(scope);
@@ -118,6 +163,9 @@ function Chat({
118
163
  const [listRevision, setListRevision] = useState(0);
119
164
  const root = useRef<HTMLDivElement>(null);
120
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;
121
169
 
122
170
  const select = useCallback(
123
171
  (next: string | null) => {
@@ -129,15 +177,28 @@ function Chat({
129
177
  );
130
178
 
131
179
  const create = useCallback(
132
- async (initialMessage: string, idempotencyKey: string): Promise<string> => {
133
- if (createSession) return await createSession(initialMessage, idempotencyKey);
180
+ async (
181
+ initialMessage: string,
182
+ idempotencyKey: string,
183
+ options: OpenGeniChatCreateOptions,
184
+ ): Promise<string> => {
185
+ if (createSession) return await createSession(initialMessage, idempotencyKey, options);
134
186
  const creator = (context.client as unknown as CreateClient).createSession;
135
187
  if (typeof creator !== "function") throw new Error(labels.newChatUnavailable);
136
- const created = await creator.call(context.client, context.workspaceId, {
137
- initialMessage,
138
- idempotencyKey,
139
- } as Parameters<NonNullable<CreateClient["createSession"]>>[1]);
140
- 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
+ }
141
202
  },
142
203
  [context.client, context.workspaceId, createSession, labels.newChatUnavailable],
143
204
  );
@@ -149,7 +210,7 @@ function Chat({
149
210
  labels={labels}
150
211
  selectedSessionId={selected}
151
212
  onSelect={select}
152
- onNewChat={newChat ? () => select(null) : undefined}
213
+ onNewChat={newChat && canCreate ? () => select(null) : undefined}
153
214
  onArchived={(archivedId) => {
154
215
  if (archivedId === selected) select(null);
155
216
  }}
@@ -217,13 +278,19 @@ function Chat({
217
278
  {selected ? (
218
279
  <SessionConversation
219
280
  {...conversationProps}
281
+ // Sub-agent chats open in place, like a chat from the list.
282
+ onOpenSession={conversationProps?.onOpenSession ?? select}
220
283
  {...scope}
221
284
  sessionId={selected}
222
285
  height="100%"
223
286
  />
224
287
  ) : (
225
288
  <NewChat
289
+ scope={scope}
226
290
  labels={labels}
291
+ placeholderOverride={labelOverrides?.newChatPlaceholder}
292
+ conversationProps={conversationProps}
293
+ available={canCreate}
227
294
  create={create}
228
295
  onCreated={(id) => {
229
296
  setListRevision((revision) => revision + 1);
@@ -237,43 +304,146 @@ function Chat({
237
304
  );
238
305
  }
239
306
 
307
+ const NOOP_ASYNC = async () => {};
308
+
309
+ /**
310
+ * The first message of a new chat, in the same composer as follow-ups: file
311
+ * attachments, the model picker when offered, and the host's `composerProps`
312
+ * (custom controls, voice input, copy).
313
+ */
240
314
  function NewChat({
315
+ scope,
241
316
  labels,
317
+ placeholderOverride,
318
+ conversationProps,
319
+ available,
242
320
  create,
243
321
  onCreated,
244
322
  }: {
323
+ scope: ClientOverride;
245
324
  labels: OpenGeniChatLabels;
246
- create: (initialMessage: string, idempotencyKey: string) => Promise<string>;
325
+ placeholderOverride: string | undefined;
326
+ conversationProps: OpenGeniChatProps["conversationProps"];
327
+ /** False when this product cannot start chats; the composer is disabled and says so. */
328
+ available: boolean;
329
+ create: (
330
+ initialMessage: string,
331
+ idempotencyKey: string,
332
+ options: OpenGeniChatCreateOptions,
333
+ ) => Promise<string>;
247
334
  onCreated: (sessionId: string) => void;
248
335
  }) {
336
+ const context = useOpenGeni(scope);
337
+ const config = useClientConfigFlags(context.client);
338
+ const composerProps = conversationProps?.composerProps;
339
+ const modelPickerProps = conversationProps?.modelPickerProps;
340
+ const showModelPicker = conversationProps?.modelPicker ?? config.modelSelection;
341
+ const catalog = useWorkspaceModelCatalog({
342
+ client: context.client,
343
+ workspaceId: context.workspaceId,
344
+ enabled: showModelPicker,
345
+ });
346
+ const files = useFileAttachments(scope);
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
+ );
249
353
  const [text, setText] = useState("");
250
354
  const [sending, setSending] = useState(false);
251
355
  const [error, setError] = useState<{ cause: unknown } | null>(null);
356
+ // Only what the user changed is sent; everything else stays server-owned.
357
+ const [choice, setChoice] = useState<
358
+ Pick<OpenGeniChatCreateOptions, "model" | "reasoningEffort" | "latencyMode">
359
+ >({});
252
360
  const formatError = useErrorMessage();
253
- // One key per draft, so a retried send returns the same chat.
254
- const idempotencyKey = useRef(crypto.randomUUID());
361
+ // One key per attempted payload, so a retried send returns the same chat
362
+ // and an edited retry is a new request rather than an idempotency conflict.
363
+ const attempt = useRef<{ fingerprint: string; key: string } | null>(null);
364
+ const resources = uploadsEnabled ? files.readyResources : [];
365
+ const pendingFiles = uploadsEnabled && files.hasUnresolved;
255
366
 
256
- const submit = async (event: FormEvent) => {
257
- event.preventDefault();
258
- const initialMessage = text.trim();
259
- if (!initialMessage || sending) return;
367
+ const submit = async (): Promise<boolean> => {
368
+ const initialMessage = text.trim() || (resources.length > 0 ? FILE_ONLY_MESSAGE_TEXT : "");
369
+ if (!initialMessage || sending || pendingFiles || !available) return false;
370
+ const options: OpenGeniChatCreateOptions = {
371
+ ...(resources.length > 0 ? { resources } : {}),
372
+ ...(showModelPicker ? choice : {}),
373
+ };
374
+ const fingerprint = JSON.stringify([initialMessage, options]);
375
+ if (attempt.current?.fingerprint !== fingerprint) {
376
+ attempt.current = { fingerprint, key: crypto.randomUUID() };
377
+ }
260
378
  setSending(true);
261
379
  setError(null);
262
380
  try {
263
- const id = await create(initialMessage, idempotencyKey.current);
264
- idempotencyKey.current = crypto.randomUUID();
381
+ const id = await create(initialMessage, attempt.current.key, options);
382
+ attempt.current = null;
265
383
  setText("");
384
+ files.removeReadyFiles(resources.map((resource) => resource.fileId));
266
385
  onCreated(id);
386
+ return true;
267
387
  } catch (cause) {
268
388
  setError({ cause });
389
+ return false;
269
390
  } finally {
270
391
  setSending(false);
271
392
  }
272
393
  };
273
394
 
395
+ const defaultModel = catalog.defaultModel;
396
+ const policy =
397
+ showModelPicker && (choice.model ?? defaultModel)
398
+ ? {
399
+ model: (choice.model ?? defaultModel)!,
400
+ reasoningEffort:
401
+ choice.reasoningEffort ?? config.defaultReasoningEffort ?? ("medium" as const),
402
+ latencyMode: choice.latencyMode ?? ("standard" as const),
403
+ }
404
+ : null;
405
+
406
+ const composer: ComposerState = {
407
+ value: text,
408
+ setValue: setText,
409
+ hasDraftContent: () => text.length > 0 || files.attachments.length > 0,
410
+ send: submit,
411
+ steer: submit,
412
+ sending,
413
+ canSend:
414
+ available && (text.trim().length > 0 || resources.length > 0) && !sending && !pendingFiles,
415
+ pause: NOOP_ASYNC,
416
+ pausing: false,
417
+ resume: NOOP_ASYNC,
418
+ resumeScope: NOOP_ASYNC,
419
+ resuming: false,
420
+ draft: null,
421
+ draftRevision: 0,
422
+ draftLoading: false,
423
+ draftSaving: false,
424
+ draftConflict: null,
425
+ policy,
426
+ setModel: (model) => setChoice((current) => ({ ...current, model })),
427
+ setReasoningEffort: (reasoningEffort) =>
428
+ setChoice((current) => ({ ...current, reasoningEffort })),
429
+ setLatencyMode: (latencyMode) => setChoice((current) => ({ ...current, latencyMode })),
430
+ draftPersistence: "disabled",
431
+ applyDraft: () => {},
432
+ reloadDraft: NOOP_ASYNC,
433
+ resolveDraftConflict: NOOP_ASYNC,
434
+ restoredResources: [],
435
+ removeRestoredResource: () => {},
436
+ // Shown below with the product's own copy.
437
+ error: null,
438
+ clearError: () => setError(null),
439
+ };
440
+
274
441
  return (
275
442
  <form
276
- onSubmit={(event) => void submit(event)}
443
+ onSubmit={(event: FormEvent) => {
444
+ event.preventDefault();
445
+ void submit();
446
+ }}
277
447
  className="mx-auto box-border flex h-full w-full max-w-3xl flex-col gap-4 p-3"
278
448
  data-og-new-chat-composer=""
279
449
  >
@@ -282,47 +452,50 @@ function NewChat({
282
452
  {labels.newChatTitle ? (
283
453
  <p className="text-center text-og-md font-medium text-og-fg">{labels.newChatTitle}</p>
284
454
  ) : null}
285
- <div
286
- className={cn(
287
- "flex items-end gap-2 rounded-og-lg border border-og-border/90 bg-og-surface-1 p-2 pl-3.5 shadow-og-sm",
288
- "transition-[border-color,box-shadow] duration-200 ease-og-out",
289
- "focus-within:border-og-accent/50 focus-within:shadow-og-glow",
290
- )}
291
- >
292
- <textarea
293
- value={text}
294
- onChange={(event) => setText(event.target.value)}
295
- onKeyDown={(event) => {
296
- if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
297
- event.preventDefault();
298
- event.currentTarget.form?.requestSubmit();
299
- }
300
- }}
301
- placeholder={labels.newChatPlaceholder}
302
- aria-label={labels.newChatPlaceholder}
303
- rows={2}
304
- disabled={sending}
305
- className="min-h-12 flex-1 resize-none bg-transparent py-1 text-og-composer text-og-fg outline-hidden placeholder:text-og-fg-subtle md:text-og-composer-wide"
306
- />
307
- <button
308
- type="submit"
309
- aria-label={labels.send}
310
- disabled={sending || !text.trim()}
311
- className={cn(
312
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
313
- "border border-og-primary-border bg-og-primary text-og-primary-fg",
314
- "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
315
- "hover:bg-og-primary-hover active:scale-95 disabled:cursor-not-allowed disabled:opacity-50",
316
- )}
317
- >
318
- {sending ? (
319
- <LoaderCircleIcon className="size-4 animate-og-spin" aria-hidden />
320
- ) : (
321
- <ArrowUpIcon className="size-4" aria-hidden />
322
- )}
323
- </button>
324
- </div>
325
- {error ? (
455
+ <ChatComposer
456
+ {...composerProps}
457
+ {...(transcription && composerProps?.transcription === undefined ? { transcription } : {})}
458
+ composer={composer}
459
+ attachments={uploadsEnabled ? files : undefined}
460
+ disabled={!available || composerProps?.disabled}
461
+ runControl="none"
462
+ running={false}
463
+ placeholder={placeholderOverride ?? composerProps?.placeholder ?? labels.newChatPlaceholder}
464
+ inputProps={{
465
+ "aria-label": placeholderOverride ?? labels.newChatPlaceholder,
466
+ ...composerProps?.inputProps,
467
+ }}
468
+ messages={{ sendMessageAriaLabel: labels.send, ...composerProps?.messages }}
469
+ controlsStart={
470
+ composerProps?.controlsStart ??
471
+ (policy ? (
472
+ <ModelPolicyPicker
473
+ groupPresentation={modelPickerProps?.groupPresentation}
474
+ messages={modelPickerProps?.messages}
475
+ rows={catalog.rows}
476
+ model={policy.model}
477
+ effort={policy.reasoningEffort}
478
+ latencyMode={policy.latencyMode}
479
+ loading={catalog.loading}
480
+ error={catalog.error?.message}
481
+ disabled={sending}
482
+ menuSide="bottom"
483
+ onOpenChange={(open) => {
484
+ if (open) void catalog.refresh();
485
+ }}
486
+ onModelChange={composer.setModel!}
487
+ onEffortChange={composer.setReasoningEffort!}
488
+ onLatencyModeChange={composer.setLatencyMode!}
489
+ />
490
+ ) : undefined)
491
+ }
492
+ responsiveBasis={composerProps?.responsiveBasis ?? "container"}
493
+ />
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 ? (
326
499
  <p role="alert" className="text-center text-og-sm text-og-status-failed">
327
500
  {formatError(
328
501
  error.cause,