@chai-ui/react 0.6.0 → 0.7.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 (47) hide show
  1. package/DESIGN.md +19 -3
  2. package/dist/ChaiProvider.d.ts +3 -42
  3. package/dist/ChaiProvider.d.ts.map +1 -1
  4. package/dist/ChaiProvider.js +5 -43
  5. package/dist/ChaiProvider.js.map +1 -1
  6. package/dist/Composer.d.ts +4 -59
  7. package/dist/Composer.d.ts.map +1 -1
  8. package/dist/Composer.js +50 -169
  9. package/dist/Composer.js.map +1 -1
  10. package/dist/EditCard.d.ts +2 -16
  11. package/dist/EditCard.d.ts.map +1 -1
  12. package/dist/EditCard.js +16 -115
  13. package/dist/EditCard.js.map +1 -1
  14. package/dist/MediaAnalyzer.d.ts +3 -49
  15. package/dist/MediaAnalyzer.d.ts.map +1 -1
  16. package/dist/MediaAnalyzer.js +13 -141
  17. package/dist/MediaAnalyzer.js.map +1 -1
  18. package/dist/ResultCard.d.ts +13 -0
  19. package/dist/ResultCard.d.ts.map +1 -1
  20. package/dist/ResultCard.js +2 -48
  21. package/dist/ResultCard.js.map +1 -1
  22. package/dist/useComposer.d.ts +11 -86
  23. package/dist/useComposer.d.ts.map +1 -1
  24. package/dist/useComposer.js +26 -291
  25. package/dist/useComposer.js.map +1 -1
  26. package/dist/useMediaAnalyzer.d.ts +8 -28
  27. package/dist/useMediaAnalyzer.d.ts.map +1 -1
  28. package/dist/useMediaAnalyzer.js +9 -106
  29. package/dist/useMediaAnalyzer.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/ChaiProvider.tsx +12 -70
  32. package/src/Composer.tsx +73 -258
  33. package/src/EditCard.tsx +46 -139
  34. package/src/MediaAnalyzer.tsx +31 -176
  35. package/src/ResultCard.tsx +13 -38
  36. package/src/useComposer.ts +33 -411
  37. package/src/useMediaAnalyzer.ts +15 -146
  38. package/dist/media-actions.d.ts +0 -18
  39. package/dist/media-actions.d.ts.map +0 -1
  40. package/dist/media-actions.js +0 -56
  41. package/dist/media-actions.js.map +0 -1
  42. package/dist/regions.d.ts +0 -3
  43. package/dist/regions.d.ts.map +0 -1
  44. package/dist/regions.js +0 -7
  45. package/dist/regions.js.map +0 -1
  46. package/src/media-actions.ts +0 -54
  47. package/src/regions.ts +0 -7
@@ -1,88 +1,30 @@
1
1
  import { createContext, useContext, useMemo, type ReactNode } from "react";
2
- import { createOpenRouterEngine, type DroppedMedia, type GenerationEngine, type MediaKind } from "@chai-ui/core";
2
+ import { createReasoning, reasoningEngineFor, type Reasoning, type ReasoningConfig } from "@chai-ui/core";
3
3
 
4
- /**
5
- * The suggested reasoning model for text and images: prompt enhancement,
6
- * auto-select routing and image analysis. A default, not a requirement —
7
- * pass any model id `reasoningEngine` can run.
8
- */
9
- export const DEFAULT_REASONING_MODEL = "anthropic/claude-opus-5";
10
-
11
- /**
12
- * Suggested per-kind overrides for media Claude can't read. Gemini takes
13
- * video and audio directly. For a cheaper option, swap in
14
- * `"qwen/qwen3.8-omni-flash"`, which takes both too.
15
- */
16
- export const DEFAULT_REASONING_MODEL_BY_KIND: Partial<Record<MediaKind, string>> = {
17
- video: "google/gemini-3.8-flash",
18
- audio: "google/gemini-3.8-flash",
19
- };
20
-
21
- /** The reasoning engine and models every CHAI component shares. */
22
- export interface Reasoning {
23
- engine: GenerationEngine;
24
- /** The model for text and any attachment kind without its own entry in `modelByKind`. */
25
- model: string;
26
- modelByKind: Partial<Record<MediaKind, string>>;
27
- }
4
+ export {
5
+ DEFAULT_REASONING_MODEL,
6
+ DEFAULT_REASONING_MODEL_BY_KIND,
7
+ reasoningModelFor,
8
+ type Reasoning,
9
+ } from "@chai-ui/core";
28
10
 
29
11
  const ReasoningContext = createContext<Reasoning | undefined>(undefined);
30
12
 
31
- export interface ChaiProviderProps {
32
- /** Text, image and routing model. Defaults to `DEFAULT_REASONING_MODEL`. */
33
- reasoningModel?: string;
34
- /**
35
- * Per-attachment-kind models, merged over `DEFAULT_REASONING_MODEL_BY_KIND`
36
- * one kind at a time, e.g. `{ video: "qwen/qwen3.8-omni-flash" }` keeps
37
- * the default for audio. Map a kind to `reasoningModel`'s value to use
38
- * one model for everything.
39
- */
40
- reasoningModelByKind?: Partial<Record<MediaKind, string>>;
41
- /**
42
- * The engine the reasoning models run on. Defaults to OpenRouter,
43
- * independent of the generation engine: Fal's own LLM access is an
44
- * OpenRouter proxy, so defaulting to it would double-proxy.
45
- */
46
- reasoningEngine?: GenerationEngine;
13
+ export interface ChaiProviderProps extends ReasoningConfig {
47
14
  children?: ReactNode;
48
15
  }
49
16
 
50
17
  export function ChaiProvider({ reasoningModel, reasoningModelByKind, reasoningEngine, children }: ChaiProviderProps) {
51
- const engine = useMemo(() => reasoningEngine ?? createOpenRouterEngine(), [reasoningEngine]);
52
- const model = reasoningModel ?? DEFAULT_REASONING_MODEL;
53
- const video = reasoningModelByKind?.video;
54
- const audio = reasoningModelByKind?.audio;
55
- const image = reasoningModelByKind?.image;
56
- const text = reasoningModelByKind?.text;
18
+ const engine = useMemo(() => reasoningEngineFor({ reasoningEngine }), [reasoningEngine]);
19
+ const { video, audio, image, text } = reasoningModelByKind ?? {};
57
20
  const value = useMemo(
58
- () => ({
59
- engine,
60
- model,
61
- modelByKind: { ...DEFAULT_REASONING_MODEL_BY_KIND, ...dropUndefined({ video, audio, image, text }) },
62
- }),
63
- [engine, model, video, audio, image, text]
21
+ () => createReasoning({ reasoningModel, reasoningModelByKind: { video, audio, image, text } }, engine),
22
+ [engine, reasoningModel, video, audio, image, text]
64
23
  );
65
24
  return <ReasoningContext.Provider value={value}>{children}</ReasoningContext.Provider>;
66
25
  }
67
26
 
68
- function dropUndefined<T extends object>(obj: T): Partial<T> {
69
- return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
70
- }
71
-
72
27
  /** The nearest `ChaiProvider`'s reasoning engine and models, or `undefined` outside one. */
73
28
  export function useReasoning(): Reasoning | undefined {
74
29
  return useContext(ReasoningContext);
75
30
  }
76
-
77
- /**
78
- * The reasoning model for a call with these attachments: the first
79
- * attachment whose kind has its own model wins (a clip plus a still goes
80
- * to the model that can read the clip), else `reasoning.model`.
81
- */
82
- export function reasoningModelFor(reasoning: Reasoning, attachments: DroppedMedia[] = []): string {
83
- for (const { kind } of attachments) {
84
- const model = reasoning.modelByKind[kind];
85
- if (model) return model;
86
- }
87
- return reasoning.model;
88
- }
package/src/Composer.tsx CHANGED
@@ -3,10 +3,29 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react";
3
3
  import { createComponent } from "@lit/react";
4
4
  import * as React from "react";
5
5
  import { MdFab } from "@material/web/fab/fab.js";
6
- import { deriveGenerationUseCase, suggestedModels } from "@chai-ui/core";
7
- import type { DroppedMedia, EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
6
+ import {
7
+ composerAttachmentsFromFiles,
8
+ composerAttachMenuItems,
9
+ composerView,
10
+ enhanceRevealDurationMs,
11
+ MAX_ANIMATE_ENHANCE_LENGTH,
12
+ regionColor,
13
+ TEXT_USE_CASE,
14
+ type ComposerAttachMenuAction,
15
+ } from "@chai-ui/core";
16
+ import type { EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
8
17
  import { SearchMenu } from "./primitives/SearchMenu.js";
9
- import { regionColor } from "./regions.js";
18
+ import type { ComposerAttachment, ComposerSubmitPayload, ComposerUseCase } from "@chai-ui/core";
19
+
20
+ export {
21
+ EDIT_IMAGE_USE_CASE,
22
+ TEXT_USE_CASE,
23
+ type ComposerAttachMenuAction,
24
+ type ComposerAttachment,
25
+ type ComposerSelection,
26
+ type ComposerSubmitPayload,
27
+ type ComposerUseCase,
28
+ } from "@chai-ui/core";
10
29
 
11
30
  /** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
12
31
  const MdFabElement = createComponent({
@@ -16,101 +35,6 @@ const MdFabElement = createComponent({
16
35
  events: { onClick: "click" },
17
36
  });
18
37
 
19
- const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
20
-
21
- function detectKind(file: File): MediaKind | undefined {
22
- const prefix = file.type.split("/")[0];
23
- return ACCEPTED_KINDS.find((k) => k === prefix);
24
- }
25
-
26
- // Past this length the rewritten prompt just appears instantly — a
27
- // multi-hundred-character reveal reads as sluggish, not delightful, and a
28
- // long prompt is exactly the case where a person wants the result now, not
29
- // a show. Picked to comfortably cover a typical Enhance rewrite (usually a
30
- // sentence or two of added detail) while bailing out before it doesn't.
31
- const MAX_ANIMATE_ENHANCE_LENGTH = 260;
32
-
33
- // Time-driven (see the `requestAnimationFrame` loop that uses this), not
34
- // count-driven — a fixed ms-per-character reveal on a slow frame would
35
- // drift or stall instead of just finishing a beat later. Capped on both
36
- // ends: a two-word rewrite shouldn't take as long as a five-word one to
37
- // feel intentional, and even a near-max-length one should still resolve
38
- // under a second.
39
- function enhanceRevealDurationMs(charCount: number): number {
40
- return Math.min(900, Math.max(300, charCount * 6));
41
- }
42
-
43
- function nextAttachmentId(): string {
44
- return typeof crypto !== "undefined" && "randomUUID" in crypto
45
- ? crypto.randomUUID()
46
- : `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
47
- }
48
-
49
- export interface ComposerAttachment extends DroppedMedia {
50
- id: string;
51
- }
52
-
53
- export type ComposerAttachMenuAction = "add-media" | "create-image" | "create-video" | "edit-media";
54
-
55
- /**
56
- * The selected generation use case, shown as a removable chip (reference:
57
- * the mock's "Video"/"Image" chip) — deliberately independent of
58
- * `attachments`: a use case can be picked from the attach menu with nothing
59
- * attached yet (e.g. "Create video"). Composer never derives this from
60
- * `attachments` itself (that's app-level policy, not a Composer opinion —
61
- * the design). With none picked, `defaultUseCase` applies.
62
- */
63
- export interface ComposerUseCase {
64
- kind: MediaKind;
65
- label: string;
66
- /**
67
- * An edit of the attached image rather than a new one: no aspect ratio,
68
- * region chips when `regions` is set, and a submit needs only an image and
69
- * either a prompt or a region. Images only for now.
70
- */
71
- edit?: boolean;
72
- }
73
-
74
- /** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
75
- export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
76
-
77
- /** Editing the attached image, the attach menu's "Edit image". Pair it with `useComposer`'s `editImage` and an `EditCard`. */
78
- export const EDIT_IMAGE_USE_CASE: ComposerUseCase = { kind: "image", label: "Edit image", edit: true };
79
-
80
- /** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
81
- export interface ComposerSelection {
82
- useCase: ComposerUseCase;
83
- modelIds: string[];
84
- /** The models offered for this use case: what auto-select routes among. */
85
- models: ModelOption[];
86
- }
87
-
88
- /**
89
- * Everything Composer itself tracks, assembled into one object at submit
90
- * time — not the the design `Request` shape (no `mode`/`params`/
91
- * `presetId`; those belong to the run store this package doesn't have yet,
92
- * see the package README), just an honest snapshot of this component's own
93
- * state. Handed to `onSubmit` so a builder doesn't have to re-derive it
94
- * from five separately-controlled props they already hold — mainly a
95
- * correctness/ergonomics win for a coding agent wiring this up from the
96
- * type signature alone.
97
- */
98
- export interface ComposerSubmitPayload {
99
- value: string;
100
- attachments: ComposerAttachment[];
101
- /** The use case to run — the picked one, or `defaultUseCase` when none is picked. The first one with `multiSelectUseCases`. */
102
- useCase: ComposerUseCase;
103
- /** The first picked model, or null for the engine's default. */
104
- modelId: string | null;
105
- aspectRatio: string | null;
106
- /** Everything to run: one entry per use case with its picked models. A single entry unless `multiSelectUseCases` is on. */
107
- selections: ComposerSelection[];
108
- /** When true, the model is chosen at submit (`useComposer` routes among each selection's `models`), not by the picks. */
109
- autoSelectModel: boolean;
110
- /** An edit's marked regions, each with its own instruction. Empty unless the use case is an edit. */
111
- regions?: EditRegion[];
112
- }
113
-
114
38
  export interface ComposerProps {
115
39
  /** The prompt text. Controlled — Composer owns no text state of its own. */
116
40
  value: string;
@@ -292,92 +216,36 @@ export function Composer({
292
216
  onRegionsChange,
293
217
  disabled = false,
294
218
  }: ComposerProps) {
295
- // What the person picked, and what actually runs: with nothing picked,
296
- // the builder's default use case (a text request unless they chose
297
- // otherwise), so a typed prompt always goes somewhere.
298
- const pickedUseCases = multiSelectUseCases ? useCases : useCase ? [useCase] : [];
299
- const activeUseCases = pickedUseCases.length > 0 ? pickedUseCases : [defaultUseCase];
300
- // An edit needs an image, then a change: a prompt for the whole image, a
301
- // region with its own instruction, or both.
302
- const editing = activeUseCases.some((u) => u.edit);
303
- const hasImage = attachments.some((a) => a.kind === "image");
304
- const hasContent = editing
305
- ? hasImage && (value.trim().length > 0 || regions.length > 0)
306
- : value.trim().length > 0 || attachments.length > 0;
307
- const isSubmitDisabled = submitError ? disabled : (submitDisabled ?? (disabled || !hasContent));
308
- const effectivePlaceholder =
309
- placeholder ??
310
- (editing
311
- ? hasImage
312
- ? "Describe a change across the whole image, or leave this empty"
313
- : "Attach an image to edit"
314
- : "Describe media to create");
315
- // The stop icon only appears with somewhere for its click to go — without
316
- // `onAbort`, `submitting` just disables the FAB instead (below), same as
317
- // it would with no opt-in wired at all.
318
- const showStop = submitting && Boolean(onAbort);
319
- const multiModel = multiSelectModels || multiSelectUseCases;
320
- const rawPickedIds = multiModel ? modelIds : modelId ? [modelId] : [];
321
- // The app's list for a use case, or Chai's suggested models when it gave none.
322
- const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
323
- const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
324
- if (given) return { options: given, suggested: false };
325
- return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
326
- };
327
- // One menu section per use case with multi-use-case select; otherwise one flat roster.
328
- const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
329
- .map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
330
- .filter((s) => s.options.length > 0);
331
- const allModels = modelSections.flatMap((s) => s.options);
332
- // A pick that isn't in the current list (e.g. the edit model after leaving
333
- // edit mode) is shown as unpicked and never submitted. With no list at
334
- // all, the app's pick is passed through as given. With nothing picked
335
- // from a suggested list (and auto-select off), its first model is the pick.
336
- const validPicks =
337
- allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
338
- const pickedModelIds =
339
- validPicks.length > 0 || autoSelectModel
340
- ? validPicks
341
- : modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
342
- const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
343
- // No menu that can't change anything: without its handler, the Model menu
344
- // hides (the default pick still runs).
345
- const canPickModel = multiModel ? Boolean(onModelIdsChange) : Boolean(onModelChange);
346
- const modelTriggerLabel =
347
- pickedModels.length > 1
348
- ? `${pickedModels.length} models`
349
- : pickedModels[0]?.label ?? (autoSelectModel ? "Auto-select" : "Select models");
350
- // An edit keeps the source image's shape.
351
- const showAspectRatio = activeUseCases.some((u) => !u.edit && (u.kind === "image" || u.kind === "video"));
352
- // Only ratios every model that might run takes: the picked ones, or with
353
- // none picked (or auto-select on) the whole list, so whichever runs can
354
- // honor it. A model with no `aspectRatios` sets its own shape: no menu.
355
- const ratioModels = pickedModels.length > 0 && !autoSelectModel ? pickedModels : allModels;
356
- const sharedRatios =
357
- ratioModels.reduce<string[] | null>(
358
- (shared, m) => (shared ?? m.aspectRatios ?? []).filter((r) => m.aspectRatios?.includes(r)),
359
- null
360
- ) ?? [];
361
- const aspectOptions = aspectRatios
362
- ? aspectRatios.filter((o) => sharedRatios.includes(o.value))
363
- : sharedRatios.map((r) => ({ value: r, label: r }));
364
- // A pick the current models don't take is shown as unpicked and never sent.
365
- const offeredAspect = showAspectRatio && aspectOptions.some((o) => o.value === aspectRatio) ? aspectRatio : null;
366
- const selectedAspect = aspectOptions.find((o) => o.value === offeredAspect) ?? null;
219
+ const view = composerView({
220
+ value,
221
+ placeholder,
222
+ attachments,
223
+ useCase,
224
+ defaultUseCase,
225
+ multiSelectUseCases,
226
+ useCases,
227
+ modelsByKind,
228
+ multiSelectModels,
229
+ modelIds,
230
+ models,
231
+ modelId,
232
+ onModelChange,
233
+ onModelIdsChange,
234
+ autoSelectModel,
235
+ aspectRatios,
236
+ aspectRatio,
237
+ submitError,
238
+ submitDisabled,
239
+ submitting,
240
+ onAbort,
241
+ regions,
242
+ disabled,
243
+ });
244
+ const { editing, isSubmitDisabled, showStop, multiModel, modelSections, pickedModelIds, aspectOptions, offeredAspect } = view;
367
245
 
368
246
  function toggleModel(id: string) {
369
247
  if (!multiModel) return onModelChange?.(id);
370
- onModelIdsChange?.(modelIds.includes(id) ? modelIds.filter((m) => m !== id) : [...modelIds, id]);
371
- }
372
-
373
- function buildSelections(): ComposerSelection[] {
374
- return activeUseCases.map((u) => ({
375
- useCase: u,
376
- models: rosterFor(u).options,
377
- modelIds: multiSelectUseCases
378
- ? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id))
379
- : pickedModelIds,
380
- }));
248
+ onModelIdsChange?.(view.toggledModelIds(id));
381
249
  }
382
250
 
383
251
  // Auto-select chooses at submit, from the prompt (`useComposer` routes
@@ -466,54 +334,19 @@ export function Composer({
466
334
  onEnhance?.(value);
467
335
  }
468
336
 
469
- function handleFilesSelected(fileList: FileList | null) {
337
+ async function handleFilesSelected(fileList: FileList | null) {
470
338
  if (!fileList || fileList.length === 0 || !onAttachmentsChange) return;
471
339
  // Edit mode works on one image: a new one replaces it, and the edit starts over.
472
- if (editing) {
473
- const image = Array.from(fileList).find((f) => detectKind(f) === "image");
474
- if (!image) {
475
- Array.from(fileList).forEach((f) => onUnsupportedFile?.(f.name));
476
- return;
477
- }
478
- const reader = new FileReader();
479
- reader.onload = () =>
480
- onAttachmentsChange([{ id: nextAttachmentId(), src: String(reader.result), kind: "image", name: image.name, size: image.size }]);
481
- reader.onerror = () => onUnsupportedFile?.(image.name);
482
- reader.readAsDataURL(image);
483
- return;
484
- }
485
- const next = [...attachments];
486
- let pending = fileList.length;
487
- const settle = () => {
488
- pending -= 1;
489
- if (pending === 0) onAttachmentsChange(next);
490
- };
491
- Array.from(fileList).forEach((file) => {
492
- const kind = detectKind(file);
493
- if (!kind) {
494
- onUnsupportedFile?.(file.name);
495
- settle();
496
- return;
497
- }
498
- const reader = new FileReader();
499
- reader.onload = () => {
500
- next.push({ id: nextAttachmentId(), src: String(reader.result), kind, name: file.name, size: file.size });
501
- settle();
502
- };
503
- reader.onerror = () => {
504
- onUnsupportedFile?.(file.name);
505
- settle();
506
- };
507
- reader.readAsDataURL(file);
508
- });
340
+ const { attachments: next, unsupported } = await composerAttachmentsFromFiles(fileList, attachments, editing);
341
+ unsupported.forEach((name) => onUnsupportedFile?.(name));
342
+ if (next) onAttachmentsChange(next);
509
343
  }
510
344
 
511
345
  // Only items that can work: no menu item that silently does nothing.
512
- const menuItems = ATTACH_MENU_ITEMS.filter(
513
- (item) =>
514
- (attachMenuActions ?? ATTACH_MENU_ITEMS.map((i) => i.action)).includes(item.action) &&
515
- (item.action === "add-media" ? Boolean(onAttachmentsChange) : Boolean(onAttachMenuSelect))
516
- );
346
+ const menuItems = composerAttachMenuItems(attachMenuActions, {
347
+ canAddMedia: Boolean(onAttachmentsChange),
348
+ canSelect: Boolean(onAttachMenuSelect),
349
+ });
517
350
 
518
351
  function handleRemoveAttachment(id: string) {
519
352
  onAttachmentsChange?.(attachments.filter((a) => a.id !== id));
@@ -553,8 +386,8 @@ export function Composer({
553
386
  className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
554
387
  value={value}
555
388
  onChange={(e) => onChange(e.target.value)}
556
- placeholder={effectivePlaceholder}
557
- aria-label={promptLabel ?? effectivePlaceholder}
389
+ placeholder={view.placeholder}
390
+ aria-label={promptLabel ?? view.placeholder}
558
391
  rows={1}
559
392
  disabled={disabled || animating}
560
393
  />
@@ -610,7 +443,7 @@ export function Composer({
610
443
  />
611
444
  )}
612
445
 
613
- {pickedUseCases.map((u) => (
446
+ {view.pickedUseCases.map((u) => (
614
447
  <UseCaseChip
615
448
  key={u.kind}
616
449
  useCase={u}
@@ -629,7 +462,7 @@ export function Composer({
629
462
  trigger stays enabled whenever the switch is shown — it's the
630
463
  only way back to the switch. Rows are disabled by `SearchMenu`
631
464
  itself while auto-select is on. */}
632
- {modelSections.length > 0 && canPickModel && (
465
+ {modelSections.length > 0 && view.canPickModel && (
633
466
  <SearchMenu
634
467
  {...(multiSelectUseCases
635
468
  ? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
@@ -645,7 +478,7 @@ export function Composer({
645
478
  }
646
479
  : undefined
647
480
  }
648
- triggerLabel={modelTriggerLabel}
481
+ triggerLabel={view.modelTriggerLabel}
649
482
  menuLabel={multiModel ? "Select models" : "Select model"}
650
483
  onSelect={(opt) => toggleModel(opt.id)}
651
484
  searchable={false}
@@ -655,11 +488,11 @@ export function Composer({
655
488
  />
656
489
  )}
657
490
 
658
- {showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
491
+ {view.showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
659
492
  <SearchMenu
660
493
  options={aspectOptions.map((o) => ({ id: o.value, label: o.label }))}
661
494
  value={offeredAspect}
662
- triggerLabel={selectedAspect?.label ?? "Aspect ratio"}
495
+ triggerLabel={view.selectedAspect?.label ?? "Aspect ratio"}
663
496
  menuLabel="Aspect ratio"
664
497
  onSelect={(opt) => onAspectRatioChange?.(opt.id)}
665
498
  searchable={false}
@@ -724,19 +557,7 @@ export function Composer({
724
557
  } as Record<string, unknown>)}
725
558
  onClick={() => {
726
559
  if (showStop) onAbort?.();
727
- else if (!isSubmitDisabled && !submitting) {
728
- const selections = buildSelections();
729
- onSubmit({
730
- value,
731
- attachments,
732
- useCase: activeUseCases[0]!,
733
- modelId: pickedModelIds[0] ?? null,
734
- aspectRatio: offeredAspect,
735
- selections,
736
- autoSelectModel,
737
- regions: editing ? regions : [],
738
- });
739
- }
560
+ else if (!isSubmitDisabled && !submitting) onSubmit(view.payload());
740
561
  }}
741
562
  >
742
563
  <span slot="icon">
@@ -748,18 +569,12 @@ export function Composer({
748
569
  );
749
570
  }
750
571
 
751
- const ATTACH_MENU_ITEMS: {
752
- action: ComposerAttachMenuAction;
753
- label: string;
754
- icon: React.ReactNode;
755
- /** Reference: "Add media" (`MenuListItem1`) is the only item with `showDivider: true` in the source. */
756
- divider?: boolean;
757
- }[] = [
758
- { action: "add-media", label: "Add media", icon: <AttachFileIcon />, divider: true },
759
- { action: "create-image", label: "Create image", icon: <ImageIcon /> },
760
- { action: "create-video", label: "Create video", icon: <VideocamIcon /> },
761
- { action: "edit-media", label: "Edit image", icon: <ContentCutIcon /> },
762
- ];
572
+ const ATTACH_MENU_ICONS: Record<ComposerAttachMenuAction, React.ReactNode> = {
573
+ "add-media": <AttachFileIcon />,
574
+ "create-image": <ImageIcon />,
575
+ "create-video": <VideocamIcon />,
576
+ "edit-media": <ContentCutIcon />,
577
+ };
763
578
 
764
579
  /**
765
580
  * The "+" / "×" attach control (reference: Composer_01 "Menu open" state,
@@ -775,7 +590,7 @@ function ComposerAttachMenu({
775
590
  imagesOnly,
776
591
  disabled,
777
592
  }: {
778
- items: typeof ATTACH_MENU_ITEMS;
593
+ items: ReturnType<typeof composerAttachMenuItems>;
779
594
  onSelect?: (action: ComposerAttachMenuAction) => void;
780
595
  onAddMediaFiles: (files: FileList | null) => void;
781
596
  /** Edit mode: one image file at a time. */
@@ -832,7 +647,7 @@ function ComposerAttachMenu({
832
647
  className="chai-composer__attach-menu-item"
833
648
  onClick={() => handleItemClick(item.action)}
834
649
  >
835
- <span className="chai-composer__attach-menu-icon">{item.icon}</span>
650
+ <span className="chai-composer__attach-menu-icon">{ATTACH_MENU_ICONS[item.action]}</span>
836
651
  {item.label}
837
652
  </button>
838
653
  {item.divider && i < items.length - 1 && <hr className="chai-composer__attach-menu-divider" role="separator" />}