@chai-ui/react 0.7.1 → 0.9.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.
package/src/Composer.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { StopIcon } from "./icons.js";
2
- import { useEffect, useRef, useState, type ChangeEvent } from "react";
1
+ import { AddIcon, ArrowForwardIcon, CloseSymbolIcon, StopIcon } from "./icons.js";
2
+ import { useEffect, useId, useRef, useState, useSyncExternalStore, 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";
@@ -7,13 +7,19 @@ import {
7
7
  composerAttachmentsFromFiles,
8
8
  composerAttachMenuItems,
9
9
  composerView,
10
+ createVoiceInput,
10
11
  enhanceRevealDurationMs,
12
+ formatVoiceElapsed,
13
+ VOICE_LEVEL_COUNT,
11
14
  MAX_ANIMATE_ENHANCE_LENGTH,
12
15
  regionColor,
13
16
  TEXT_USE_CASE,
14
17
  type ComposerAttachMenuAction,
18
+ type ComposerPromptField,
19
+ type VoiceInputConfig,
15
20
  } from "@chai-ui/core";
16
- import type { EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
21
+ import { useLatest } from "./use-latest.js";
22
+ import type { EditRegion, GenerationUseCase, ModelOption, ParameterOption, MediaKind, OutputKind } from "@chai-ui/core";
17
23
  import { SearchMenu } from "./primitives/SearchMenu.js";
18
24
  import type { ComposerAttachment, ComposerSubmitPayload, ComposerUseCase } from "@chai-ui/core";
19
25
 
@@ -22,6 +28,7 @@ export {
22
28
  TEXT_USE_CASE,
23
29
  type ComposerAttachMenuAction,
24
30
  type ComposerAttachment,
31
+ type ComposerPromptField,
25
32
  type ComposerSelection,
26
33
  type ComposerSubmitPayload,
27
34
  type ComposerUseCase,
@@ -61,7 +68,7 @@ export interface ComposerProps {
61
68
  useCases?: ComposerUseCase[];
62
69
  onUseCasesChange?: (next: ComposerUseCase[]) => void;
63
70
  /** Models per use-case kind, for `multiSelectUseCases`. A kind left out gets Chai's suggested models (see `models`). */
64
- modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
71
+ modelsByKind?: Partial<Record<OutputKind, ModelOption[]>>;
65
72
  /**
66
73
  * Builder opt-in, off by default: the end user can pick several models,
67
74
  * each producing its own result (ResultCard pages through them). Uses
@@ -118,11 +125,26 @@ export interface ComposerProps {
118
125
  * runs the rewrite is still an open question at the design-doc level,
119
126
  * not just an implementation gap — see the design open question 5
120
127
  * ("Enhance model"). Receives the current prompt text so the handler is
121
- * self-contained — doesn't need its own closure over `value`.
128
+ * self-contained — doesn't need its own closure over `value`. With a
129
+ * world use case each field has its own button: `field` is "environment"
130
+ * or "character" (otherwise "prompt"), so the rewrite can fit the field,
131
+ * and the handler calls `onChange` or `onCharacterChange` to match.
132
+ * `mode` is the use case on screen, e.g. "text-to-text" with none picked:
133
+ * rewrite a text request as one, not as a media prompt.
122
134
  */
123
- onEnhance?: (value: string) => void;
124
- /** True while the builder's own `onEnhance` handler is running its LLM call — shows the button's active state. */
135
+ onEnhance?: (value: string, field: ComposerPromptField, mode?: GenerationUseCase) => void;
136
+ /** True while the builder's own `onEnhance` handler is running its LLM call — shows the active state on the button that was clicked. */
125
137
  enhancing?: boolean;
138
+ /**
139
+ * A world's character field (Create world), shown beside the environment
140
+ * while the use case is a world. `value` is the environment. Without
141
+ * `onCharacterChange`, only the environment shows.
142
+ */
143
+ character?: string;
144
+ onCharacterChange?: (value: string) => void;
145
+ /** A world's frame rate menu, from the selected models' `ModelOption.frameRates`. */
146
+ frameRate?: number | null;
147
+ onFrameRateChange?: (value: number) => void;
126
148
  /** Fires with everything Composer tracks — see `ComposerSubmitPayload`. Also what retry calls, with the same payload, when `submitError` is set. */
127
149
  onSubmit: (payload: ComposerSubmitPayload) => void;
128
150
  /**
@@ -164,6 +186,14 @@ export interface ComposerProps {
164
186
  */
165
187
  regions?: EditRegion[];
166
188
  onRegionsChange?: (next: EditRegion[]) => void;
189
+ /**
190
+ * Turns on the mic, left of submit: the person speaks, and the transcript
191
+ * is added to the prompt (never submitted). Name a Fal speech-to-text
192
+ * model, e.g. `{ model: "fal-ai/wizper" }`, and the clip goes through your
193
+ * `/api/chai/fal` route; or pass `{ transcribe }` for any other provider.
194
+ * Off by default, and there's no default model.
195
+ */
196
+ voiceInput?: VoiceInputConfig;
167
197
  disabled?: boolean;
168
198
  }
169
199
 
@@ -206,6 +236,10 @@ export function Composer({
206
236
  onAspectRatioChange,
207
237
  onEnhance,
208
238
  enhancing = false,
239
+ character = "",
240
+ onCharacterChange,
241
+ frameRate = null,
242
+ onFrameRateChange,
209
243
  onSubmit,
210
244
  submitError = null,
211
245
  submitDisabled,
@@ -215,6 +249,7 @@ export function Composer({
215
249
  attachMenuActions,
216
250
  regions = [],
217
251
  onRegionsChange,
252
+ voiceInput,
218
253
  disabled = false,
219
254
  }: ComposerProps) {
220
255
  const view = composerView({
@@ -235,6 +270,8 @@ export function Composer({
235
270
  autoSelectModel,
236
271
  aspectRatios,
237
272
  aspectRatio,
273
+ character,
274
+ frameRate,
238
275
  submitError,
239
276
  submitDisabled,
240
277
  submitting,
@@ -271,79 +308,21 @@ export function Composer({
271
308
  wasAutoSelect.current = autoSelectModel;
272
309
  }, [autoSelectModel, multiModel, onModelChange, onModelIdsChange]);
273
310
 
274
- // Auto-grows with wrapped content instead of a fixed row count — a
275
- // static `rows` either hides wrapped lines behind a scrollbar (too
276
- // short) or wastes space while empty (too tall). Starts at one line
277
- // (matching the mock's compact minimal state) and grows only as typed
278
- // text actually wraps.
279
- const textareaRef = useRef<HTMLTextAreaElement>(null);
280
- useEffect(() => {
281
- const el = textareaRef.current;
282
- if (!el) return;
283
- el.style.height = "auto";
284
- el.style.height = `${el.scrollHeight}px`;
285
- }, [value]);
286
-
287
- // The optimize/undo cycle (§ its own button below). `value` is already
288
- // the final rewritten text the instant `enhancing` drops back to false —
289
- // the reveal below is a purely cosmetic overlay on top of it, not a
290
- // delay, so nothing here ever gates what the real `<textarea>` holds.
291
- const [originalValue, setOriginalValue] = useState<string | null>(null);
292
- const [enhancedValue, setEnhancedValue] = useState<string | null>(null);
293
- const [prevEnhancing, setPrevEnhancing] = useState(enhancing);
294
- const [animatingText, setAnimatingText] = useState<string | null>(null);
295
- const [revealedLength, setRevealedLength] = useState(0);
296
-
297
- // React to `enhancing` dropping back to false while rendering, not in an
298
- // effect (the rules of React): that's the moment the rewrite has landed.
299
- if (prevEnhancing !== enhancing) {
300
- setPrevEnhancing(enhancing);
301
- if (prevEnhancing && !enhancing && originalValue != null && value !== originalValue) {
302
- setEnhancedValue(value);
303
- // Bails on length (see the constant's own comment) or on a real
304
- // `prefers-reduced-motion` request — the reveal is itself motion, not
305
- // just the shimmer the CSS `@media` query above separately turns off.
306
- const reducedMotion =
307
- typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
308
- if (value.length <= MAX_ANIMATE_ENHANCE_LENGTH && !reducedMotion) {
309
- setRevealedLength(0);
310
- setAnimatingText(value);
311
- }
312
- }
313
- }
314
-
315
- useEffect(() => {
316
- if (animatingText == null) return;
317
- const total = animatingText.length;
318
- const duration = enhanceRevealDurationMs(total);
319
- const start = performance.now();
320
- let raf: number;
321
- const tick = (now: number) => {
322
- const elapsed = now - start;
323
- const next = Math.min(total, Math.floor((elapsed / duration) * total));
324
- setRevealedLength((prev) => (prev === next ? prev : next));
325
- if (next < total) raf = requestAnimationFrame(tick);
326
- else setAnimatingText(null);
327
- };
328
- raf = requestAnimationFrame(tick);
329
- return () => cancelAnimationFrame(raf);
330
- }, [animatingText]);
331
-
332
- const animating = animatingText != null;
333
- const isEnhanced = !animating && enhancedValue != null && value === enhancedValue;
334
- const enhanceBusy = enhancing || animating;
311
+ // Which field's Enhance is running: `enhancing` is one flag for the bar.
312
+ const [enhancingField, setEnhancingField] = useState<ComposerPromptField>("prompt");
313
+ const enhanceField = (field: ComposerPromptField) =>
314
+ onEnhance &&
315
+ ((current: string) => {
316
+ setEnhancingField(field);
317
+ onEnhance(current, field, view.enhanceMode);
318
+ });
335
319
 
336
- function handleOptimizeClick() {
337
- if (isEnhanced) {
338
- const original = originalValue;
339
- setEnhancedValue(null);
340
- setOriginalValue(null);
341
- if (original != null) onChange(original);
342
- return;
343
- }
344
- setOriginalValue(value); // snapshot now — before the rewrite lands, for undo
345
- onEnhance?.(value);
346
- }
320
+ // Voice input: a transcript is added to the prompt, never submitted.
321
+ const latestPrompt = useLatest({ value, onChange });
322
+ const voice = useVoice(voiceInput, (text) => {
323
+ const { value: current, onChange: change } = latestPrompt.current;
324
+ change(current.trim() ? `${current.trimEnd()} ${text}` : text);
325
+ });
347
326
 
348
327
  async function handleFilesSelected(fileList: FileList | null) {
349
328
  if (!fileList || fileList.length === 0 || !onAttachmentsChange) return;
@@ -391,49 +370,45 @@ export function Composer({
391
370
  </ul>
392
371
  )}
393
372
 
394
- <div className="chai-composer__field">
395
- <textarea
396
- ref={textareaRef}
397
- className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
373
+ {voiceInput && voice.state.status === "recording" ? (
374
+ <VoiceWaveform levels={voice.state.levels} elapsedMs={voice.state.elapsedMs} />
375
+ ) : view.world ? (
376
+ <div className="chai-composer__world-fields">
377
+ <PromptField
378
+ value={value}
379
+ onChange={onChange}
380
+ placeholder={view.placeholder}
381
+ heading="Environment"
382
+ note="(required)"
383
+ hint={view.environmentHint}
384
+ onEnhance={enhanceField("environment")}
385
+ enhancing={enhancing && enhancingField === "environment"}
386
+ disabled={disabled}
387
+ />
388
+ {onCharacterChange && (
389
+ <PromptField
390
+ value={character}
391
+ onChange={onCharacterChange}
392
+ placeholder={view.characterPlaceholder}
393
+ heading="Character"
394
+ note="(optional)"
395
+ onEnhance={enhanceField("character")}
396
+ enhancing={enhancing && enhancingField === "character"}
397
+ disabled={disabled}
398
+ />
399
+ )}
400
+ </div>
401
+ ) : (
402
+ <PromptField
398
403
  value={value}
399
- onChange={(e) => onChange(e.target.value)}
404
+ onChange={onChange}
400
405
  placeholder={view.placeholder}
401
- aria-label={promptLabel ?? view.placeholder}
402
- rows={1}
403
- disabled={disabled || animating}
406
+ label={promptLabel}
407
+ onEnhance={enhanceField("prompt")}
408
+ enhancing={enhancing}
409
+ disabled={disabled}
404
410
  />
405
- {/* The optimize/undo reveal overlay — decorative only. The real
406
- textarea above already holds the final text throughout (just
407
- visually suppressed via `--revealing`); this div paints the
408
- same text back in progressively with a gradient, then unmounts.
409
- `aria-hidden` so a screen reader isn't told the text twice —
410
- the textarea's own (already-final) value is what it reads. */}
411
- {animating && (
412
- <div className="chai-composer__reveal chai-composer__type" aria-hidden="true">
413
- <span className="chai-composer__reveal-text">{animatingText!.slice(0, revealedLength)}</span>
414
- </div>
415
- )}
416
- {/* "Optimize prompt" / "Revert to original prompt" — see
417
- `onEnhance`'s own doc comment on `ComposerProps` for what the
418
- rewrite itself actually does and why Composer doesn't run it.
419
- Once a rewrite lands, this becomes an undo affordance instead —
420
- an already-optimized prompt can't be re-optimized — and reverts
421
- to a plain optimize button again the moment `value` no longer
422
- matches that rewrite exactly, i.e. the person has since edited
423
- it by hand. */}
424
- {onEnhance && (
425
- <button
426
- type="button"
427
- className={`chai-composer__optimize${enhanceBusy ? " chai-composer__optimize--active" : ""}`}
428
- onClick={handleOptimizeClick}
429
- disabled={disabled || (isEnhanced ? false : !value.trim() || enhanceBusy)}
430
- aria-label={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
431
- title={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
432
- >
433
- {isEnhanced ? <UndoIcon /> : <OptimizeIcon />}
434
- </button>
435
- )}
436
- </div>
411
+ )}
437
412
 
438
413
  {submitError && (
439
414
  <p className="chai-composer__error" role="alert">
@@ -442,6 +417,13 @@ export function Composer({
442
417
  </p>
443
418
  )}
444
419
 
420
+ {voiceInput && voice.state.status === "error" && (
421
+ <p className="chai-composer__error" role="alert">
422
+ <ErrorIcon />
423
+ {voice.state.error}
424
+ </p>
425
+ )}
426
+
445
427
  <div className="chai-composer__controls">
446
428
  <div className="chai-composer__controls-start">
447
429
  {menuItems.length > 0 && (
@@ -511,6 +493,19 @@ export function Composer({
511
493
  disabled={disabled}
512
494
  />
513
495
  )}
496
+
497
+ {view.frameRateOptions.length > 0 && onFrameRateChange && (
498
+ <SearchMenu
499
+ options={view.frameRateOptions.map((r) => ({ id: String(r), label: `${r}fps` }))}
500
+ value={view.offeredFrameRate == null ? null : String(view.offeredFrameRate)}
501
+ triggerLabel={view.offeredFrameRate == null ? "Frame rate" : `${view.offeredFrameRate}fps`}
502
+ menuLabel="Frame rate"
503
+ onSelect={(opt) => onFrameRateChange(Number(opt.id))}
504
+ searchable={false}
505
+ triggerVariant="text"
506
+ disabled={disabled}
507
+ />
508
+ )}
514
509
  </div>
515
510
 
516
511
  {/* MD3's `<md-fab>` has no `disabled` property of its own (FABs are
@@ -549,6 +544,11 @@ export function Composer({
549
544
  (same filled look — reads as "in progress," not "empty/error"),
550
545
  clickable regardless of `isSubmitDisabled` since a request
551
546
  already in flight isn't gated on the field having content. */}
547
+ <div className="chai-composer__controls-end">
548
+ {voiceInput && <VoiceButton voice={voice} disabled={disabled} />}
549
+ {/* No submit while a recording is being made or transcribed: the
550
+ person sends the transcript once it's in the prompt to check. */}
551
+ {!(voiceInput && (voice.state.status === "recording" || voice.state.status === "transcribing")) && (
552
552
  <MdFabElement
553
553
  className="chai-composer__submit"
554
554
  size="small"
@@ -575,7 +575,267 @@ export function Composer({
575
575
  {showStop ? <StopIcon /> : submitError ? <RefreshIcon /> : <ArrowForwardIcon />}
576
576
  </span>
577
577
  </MdFabElement>
578
+ )}
579
+ </div>
580
+ </div>
581
+ </div>
582
+ );
583
+ }
584
+
585
+ /** The core recorder, created once, with the latest config and transcript handler. */
586
+ function useVoice(config: VoiceInputConfig | undefined, onTranscript: (text: string) => void) {
587
+ const latest = useLatest({ config, onTranscript });
588
+ const [voice] = useState(() =>
589
+ createVoiceInput(
590
+ () => latest.current.config ?? { transcribe: async () => "" },
591
+ (text) => latest.current.onTranscript(text)
592
+ )
593
+ );
594
+ const state = useSyncExternalStore(voice.subscribe, voice.getState, voice.getState);
595
+ useEffect(() => () => voice.dispose(), [voice]);
596
+ return { ...voice, state };
597
+ }
598
+
599
+ /**
600
+ * What replaces the prompt while recording: the mic's recent loudness as
601
+ * bars, newest on the right, and how long it's run. Decorative: the mic
602
+ * button's label and the status line say it's recording.
603
+ */
604
+ function VoiceWaveform({ levels, elapsedMs }: { levels: number[]; elapsedMs: number }) {
605
+ const bars = [...Array<number>(Math.max(0, VOICE_LEVEL_COUNT - levels.length)).fill(0), ...levels];
606
+ return (
607
+ <div className="chai-composer__waveform" aria-hidden="true">
608
+ <div className="chai-composer__waveform-bars">
609
+ {bars.map((level, i) => (
610
+ <span key={i} className="chai-composer__waveform-bar" style={{ height: `${Math.max(20, level * 100)}%` }} />
611
+ ))}
612
+ </div>
613
+ <span className="chai-composer__waveform-time">{formatVoiceElapsed(elapsedMs)}</span>
614
+ </div>
615
+ );
616
+ }
617
+
618
+ /**
619
+ * The mic: click to record; while recording it's a stop button that ends
620
+ * the recording and transcribes it; while transcribing, a spinner, and a
621
+ * click cancels.
622
+ */
623
+ function VoiceButton({ voice, disabled }: { voice: ReturnType<typeof useVoice>; disabled: boolean }) {
624
+ const { status } = voice.state;
625
+ const label =
626
+ status === "recording" ? "Stop recording and transcribe" : status === "transcribing" ? "Cancel transcription" : "Start voice input";
627
+ function handleClick() {
628
+ if (status === "recording") voice.stop();
629
+ else if (status === "transcribing") voice.cancel();
630
+ else {
631
+ voice.dismiss();
632
+ void voice.start();
633
+ }
634
+ }
635
+ return (
636
+ <div className="chai-composer__voice" data-status={status}>
637
+ <button
638
+ type="button"
639
+ className="chai-composer__voice-button"
640
+ onClick={handleClick}
641
+ onKeyDown={(e) => {
642
+ if (e.key === "Escape" && status === "recording") voice.cancel();
643
+ }}
644
+ disabled={disabled}
645
+ aria-label={label}
646
+ title={label}
647
+ >
648
+ {status === "transcribing" ? (
649
+ <span className="chai-composer__voice-spinner" aria-hidden="true" />
650
+ ) : status === "recording" ? (
651
+ <StopIcon />
652
+ ) : (
653
+ <MicIcon />
654
+ )}
655
+ </button>
656
+ {/* Announces each change of state once, not every tick of the timer. */}
657
+ <span className="chai-visually-hidden" role="status">
658
+ {status === "recording" ? "Recording" : status === "transcribing" ? "Transcribing" : ""}
659
+ </span>
660
+ </div>
661
+ );
662
+ }
663
+
664
+ /**
665
+ * One prompt textarea with its Enhance button: grows with wrapped text,
666
+ * reveals a rewrite as it lands, and offers undo until the text is edited
667
+ * by hand. A `heading` gives it a visible label above a filled box (a
668
+ * world's Environment and Character); without one it's the bar's plain
669
+ * prompt.
670
+ */
671
+ function PromptField({
672
+ value,
673
+ onChange,
674
+ placeholder,
675
+ label,
676
+ heading,
677
+ note,
678
+ hint,
679
+ onEnhance,
680
+ enhancing,
681
+ disabled,
682
+ }: {
683
+ value: string;
684
+ onChange: (value: string) => void;
685
+ placeholder: string;
686
+ /** Accessible name for the plain prompt. Defaults to `placeholder`. */
687
+ label?: string;
688
+ heading?: string;
689
+ note?: string;
690
+ /** Shown under a headed field, e.g. why submit can't go yet. */
691
+ hint?: string | null;
692
+ onEnhance?: (value: string) => void;
693
+ enhancing: boolean;
694
+ disabled: boolean;
695
+ }) {
696
+ const id = useId();
697
+ const hintId = `${id}-hint`;
698
+
699
+ // Auto-grows with wrapped content instead of a fixed row count — a
700
+ // static `rows` either hides wrapped lines behind a scrollbar (too
701
+ // short) or wastes space while empty (too tall). Starts at one line
702
+ // (matching the mock's compact minimal state) and grows only as typed
703
+ // text actually wraps.
704
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
705
+ useEffect(() => {
706
+ const el = textareaRef.current;
707
+ if (!el) return;
708
+ el.style.height = "auto";
709
+ el.style.height = `${el.scrollHeight}px`;
710
+ }, [value]);
711
+
712
+ // The optimize/undo cycle (§ its own button below). `value` is already
713
+ // the final rewritten text the instant `enhancing` drops back to false —
714
+ // the reveal below is a purely cosmetic overlay on top of it, not a
715
+ // delay, so nothing here ever gates what the real `<textarea>` holds.
716
+ const [originalValue, setOriginalValue] = useState<string | null>(null);
717
+ const [enhancedValue, setEnhancedValue] = useState<string | null>(null);
718
+ const [prevEnhancing, setPrevEnhancing] = useState(enhancing);
719
+ const [animatingText, setAnimatingText] = useState<string | null>(null);
720
+ const [revealedLength, setRevealedLength] = useState(0);
721
+
722
+ // React to `enhancing` dropping back to false while rendering, not in an
723
+ // effect (the rules of React): that's the moment the rewrite has landed.
724
+ if (prevEnhancing !== enhancing) {
725
+ setPrevEnhancing(enhancing);
726
+ if (prevEnhancing && !enhancing && originalValue != null && value !== originalValue) {
727
+ setEnhancedValue(value);
728
+ // Bails on length (see the constant's own comment) or on a real
729
+ // `prefers-reduced-motion` request — the reveal is itself motion, not
730
+ // just the shimmer the CSS `@media` query above separately turns off.
731
+ const reducedMotion =
732
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
733
+ if (value.length <= MAX_ANIMATE_ENHANCE_LENGTH && !reducedMotion) {
734
+ setRevealedLength(0);
735
+ setAnimatingText(value);
736
+ }
737
+ }
738
+ }
739
+
740
+ useEffect(() => {
741
+ if (animatingText == null) return;
742
+ const total = animatingText.length;
743
+ const duration = enhanceRevealDurationMs(total);
744
+ const start = performance.now();
745
+ let raf: number;
746
+ const tick = (now: number) => {
747
+ const elapsed = now - start;
748
+ const next = Math.min(total, Math.floor((elapsed / duration) * total));
749
+ setRevealedLength((prev) => (prev === next ? prev : next));
750
+ if (next < total) raf = requestAnimationFrame(tick);
751
+ else setAnimatingText(null);
752
+ };
753
+ raf = requestAnimationFrame(tick);
754
+ return () => cancelAnimationFrame(raf);
755
+ }, [animatingText]);
756
+
757
+ const animating = animatingText != null;
758
+ const isEnhanced = !animating && enhancedValue != null && value === enhancedValue;
759
+ const enhanceBusy = enhancing || animating;
760
+
761
+ function handleOptimizeClick() {
762
+ if (isEnhanced) {
763
+ const original = originalValue;
764
+ setEnhancedValue(null);
765
+ setOriginalValue(null);
766
+ if (original != null) onChange(original);
767
+ return;
768
+ }
769
+ setOriginalValue(value); // snapshot now — before the rewrite lands, for undo
770
+ onEnhance?.(value);
771
+ }
772
+
773
+ const optimizeLabel = isEnhanced ? "Revert to original" : "Optimize";
774
+ const optimizeName = `${optimizeLabel} ${heading ? heading.toLowerCase() : "prompt"}`;
775
+ /* "Optimize prompt" / "Revert to original prompt" — see `onEnhance`'s own
776
+ doc comment on `ComposerProps` for what the rewrite itself actually
777
+ does and why Composer doesn't run it. Once a rewrite lands, this
778
+ becomes an undo affordance instead — an already-optimized prompt can't
779
+ be re-optimized — and reverts to a plain optimize button again the
780
+ moment `value` no longer matches that rewrite exactly, i.e. the person
781
+ has since edited it by hand. */
782
+ const optimize = onEnhance && (
783
+ <button
784
+ type="button"
785
+ className={`chai-composer__optimize${enhanceBusy ? " chai-composer__optimize--active" : ""}`}
786
+ onClick={handleOptimizeClick}
787
+ disabled={disabled || (isEnhanced ? false : !value.trim() || enhanceBusy)}
788
+ aria-label={optimizeName}
789
+ title={optimizeName}
790
+ >
791
+ {isEnhanced ? <UndoIcon /> : <OptimizeIcon />}
792
+ </button>
793
+ );
794
+
795
+ const field = (
796
+ <div className={`chai-composer__field${heading ? " chai-composer__field--boxed" : ""}`}>
797
+ <textarea
798
+ ref={textareaRef}
799
+ id={id}
800
+ className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
801
+ value={value}
802
+ onChange={(e) => onChange(e.target.value)}
803
+ placeholder={placeholder}
804
+ aria-label={heading ? undefined : (label ?? placeholder)}
805
+ aria-describedby={hint ? hintId : undefined}
806
+ rows={1}
807
+ disabled={disabled || animating}
808
+ />
809
+ {/* The optimize/undo reveal overlay — decorative only. The real
810
+ textarea above already holds the final text throughout (just
811
+ visually suppressed via `--revealing`); this div paints the
812
+ same text back in progressively with a gradient, then unmounts.
813
+ `aria-hidden` so a screen reader isn't told the text twice —
814
+ the textarea's own (already-final) value is what it reads. */}
815
+ {animating && (
816
+ <div className="chai-composer__reveal chai-composer__type" aria-hidden="true">
817
+ <span className="chai-composer__reveal-text">{animatingText!.slice(0, revealedLength)}</span>
818
+ </div>
819
+ )}
820
+ {!heading && optimize}
821
+ </div>
822
+ );
823
+
824
+ if (!heading) return field;
825
+ return (
826
+ <div className="chai-composer__world-field">
827
+ <div className="chai-composer__world-field-head">
828
+ <label htmlFor={id} className="chai-composer__world-field-label">
829
+ <strong>{heading}</strong> {note}
830
+ </label>
831
+ {optimize}
578
832
  </div>
833
+ {field}
834
+ {hint && (
835
+ <p id={hintId} className="chai-composer__field-hint">
836
+ {hint}
837
+ </p>
838
+ )}
579
839
  </div>
580
840
  );
581
841
  }
@@ -584,6 +844,7 @@ const ATTACH_MENU_ICONS: Record<ComposerAttachMenuAction, React.ReactNode> = {
584
844
  "add-media": <AttachFileIcon />,
585
845
  "create-image": <ImageIcon />,
586
846
  "create-video": <VideocamIcon />,
847
+ "create-world": <GlobeIcon />,
587
848
  "edit-media": <ContentCutIcon />,
588
849
  };
589
850
 
@@ -645,7 +906,7 @@ function ComposerAttachMenu({
645
906
  aria-haspopup="menu"
646
907
  aria-label={open ? "Close attach menu" : "Add media"}
647
908
  >
648
- {open ? <CloseIcon /> : <PlusIcon />}
909
+ {open ? <CloseSymbolIcon /> : <AddIcon />}
649
910
  </button>
650
911
 
651
912
  {open && (
@@ -693,7 +954,7 @@ function ComposerThumbnail({ attachment, onRemove }: { attachment: ComposerAttac
693
954
  )}
694
955
  {onRemove && (
695
956
  <button type="button" className="chai-composer__thumb-remove" onClick={onRemove} aria-label="Remove attachment">
696
- <CloseIcon />
957
+ <CloseSymbolIcon />
697
958
  </button>
698
959
  )}
699
960
  </div>
@@ -732,7 +993,7 @@ function UseCaseChip({
732
993
  disabled={disabled}
733
994
  aria-label={`Remove ${useCase.label}`}
734
995
  >
735
- <CloseIcon />
996
+ <CloseSymbolIcon />
736
997
  </button>
737
998
  )}
738
999
  </span>
@@ -771,7 +1032,7 @@ function RegionChip({
771
1032
  disabled={disabled}
772
1033
  aria-label={`Remove ${name}`}
773
1034
  >
774
- <CloseIcon />
1035
+ <CloseSymbolIcon />
775
1036
  </button>
776
1037
  )}
777
1038
  </span>
@@ -813,32 +1074,6 @@ function kindIcon(kind: MediaKind) {
813
1074
  // themed via the active/inactive CSS class instead of two hardcoded-color
814
1075
  // SVGs. See its button's own doc comment above for what it actually does.
815
1076
 
816
- function PlusIcon() {
817
- return (
818
- <svg viewBox="0 -960 960 960" aria-hidden="true">
819
- <path d="M450-450H200v-60h250v-250h60v250h250v60H510v250h-60v-250Z" fill="currentColor" />
820
- </svg>
821
- );
822
- }
823
-
824
- // Material Symbols "close" — reproduced here since icons aren't shared
825
- // across files yet in this codebase.
826
- function CloseIcon() {
827
- return (
828
- <svg viewBox="0 -960 960 960" aria-hidden="true">
829
- <path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" fill="currentColor" />
830
- </svg>
831
- );
832
- }
833
-
834
- function ArrowForwardIcon() {
835
- return (
836
- <svg viewBox="0 -960 960 960" aria-hidden="true">
837
- <path d="M686-450H160v-60h526L438-758l42-42 320 320-320 320-42-42 248-248Z" fill="currentColor" />
838
- </svg>
839
- );
840
- }
841
-
842
1077
  // Material Symbols "refresh" — the submit FAB's retry state.
843
1078
  function RefreshIcon() {
844
1079
  return (
@@ -902,6 +1137,30 @@ function AttachFileIcon() {
902
1137
  );
903
1138
  }
904
1139
 
1140
+ // Material Symbols "mic" — voice input.
1141
+ function MicIcon() {
1142
+ return (
1143
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
1144
+ <path
1145
+ d="M408-453.92q-29-30.91-29-75.08v-251q0-41.67 29.44-70.83Q437.88-880 479.94-880t71.56 29.17Q581-821.67 581-780v251q0 44.17-29 75.08Q523-423 480-423t-72-30.92ZM480-651Zm-30 531v-136q-106-11-178-89t-72-184h60q0 91 64.29 153t155.5 62q91.21 0 155.71-62Q700-438 700-529h60q0 106-72 184t-178 89v136h-60Zm59.5-376.5Q521-510 521-529v-251q0-17-11.79-28.5T480-820q-17.42 0-29.21 11.5T439-780v251q0 19 11.5 32.5T480-483q18 0 29.5-13.5Z"
1146
+ fill="currentColor"
1147
+ />
1148
+ </svg>
1149
+ );
1150
+ }
1151
+
1152
+ // Material Symbols "globe" — the attach menu's Create world.
1153
+ function GlobeIcon() {
1154
+ return (
1155
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
1156
+ <path
1157
+ d="M480.27-80q-82.74 0-155.5-31.5Q252-143 197.5-197.5t-86-127.34Q80-397.68 80-480.5t31.5-155.66Q143-709 197.5-763t127.34-85.5Q397.68-880 480.5-880t155.66 31.5Q709-817 763-763t85.5 127Q880-563 880-480.27q0 82.74-31.5 155.5Q817-252 763-197.68q-54 54.31-127 86Q563-80 480.27-80Zm-.27-60q142.38 0 241.19-99.5T820-480v-13q-6 26-27.41 43.5Q771.19-432 742-432h-80q-33 0-56.5-23.5T582-512v-40H422v-80q0-33 23.5-56.5T502-712h40v-22q0-16 13.5-40t30.5-29q-25-8-51.36-12.5Q508.29-820 480-820q-141 0-240.5 98.81T140-480h150q66 0 113 47t47 113v40H330v105q34 17 71.7 26t78.3 9Z"
1158
+ fill="currentColor"
1159
+ />
1160
+ </svg>
1161
+ );
1162
+ }
1163
+
905
1164
  function ImageIcon() {
906
1165
  return (
907
1166
  <svg viewBox="0 -960 960 960" aria-hidden="true">