@djangocfg/widget-chat 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useChatCopy } from "../../i18n";
4
+ import { useCapabilities } from "../../store";
5
+ import { ComposerButton } from "./button";
6
+ import { ComposerTooltip } from "./tooltip";
7
+
8
+ export interface ComposerThinkProps {
9
+ readonly className?: string;
10
+ /**
11
+ * Icon or glyph, a FUNCTION of the on/off state like `ComposerVoice`'s. The
12
+ * accessible name comes from the label, never from this.
13
+ */
14
+ readonly children?: (enabled: boolean) => ReactNode;
15
+ }
16
+
17
+ /**
18
+ * Ask the model to think longer before answering, or nothing at all.
19
+ *
20
+ * **No capability ⇒ absent button**, never a disabled one. Only a third of the
21
+ * catalogue accepts the `reasoning_effort` request field, so a toggle rendered
22
+ * unconditionally would be pressable and inert on the rest — worse than absent,
23
+ * because it looks like it worked.
24
+ *
25
+ * The state is STICKY across turns (ChatGPT's behaviour): it lives on the
26
+ * capability, which the host owns, not in the draft. A draft-held value would
27
+ * switch itself off on every send, since `send` clears the draft.
28
+ */
29
+ export function ComposerThink({ className, children }: ComposerThinkProps) {
30
+ const { think } = useCapabilities();
31
+ const copy = useChatCopy().composer;
32
+
33
+ if (!think) return null;
34
+
35
+ const label = think.enabled ? copy.thinkOff : copy.thinkOn;
36
+
37
+ return (
38
+ <ComposerTooltip label={label}>
39
+ <ComposerButton
40
+ className={className}
41
+ aria-label={label}
42
+ // A toggle, so the state is announced rather than inferred from the icon.
43
+ aria-pressed={think.enabled}
44
+ onClick={() => think.setEnabled(!think.enabled)}
45
+ >
46
+ {children?.(think.enabled)}
47
+ </ComposerButton>
48
+ </ComposerTooltip>
49
+ );
50
+ }
@@ -0,0 +1,130 @@
1
+ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2
+
3
+ import { useChatCopy } from "../../i18n";
4
+ import { useDismiss } from "../../primitives";
5
+ import { useCapabilities } from "../../store";
6
+ import type { ToolCatalogEntry } from "../../core";
7
+ import { useOptionalComposer } from "../context";
8
+ import { focusComposerField } from "../focus-field";
9
+ import { ToolMenu } from "../tools/menu";
10
+ import { ComposerButton } from "./button";
11
+
12
+ export interface ComposerToolsProps {
13
+ readonly className?: string;
14
+ /** The trigger's glyph. The accessible name comes from the label, never this. */
15
+ readonly children?: ReactNode;
16
+ /** Glyph for an entry's icon KEY. Absent, or unmapped, ⇒ a row with no glyph. */
17
+ readonly icon?: (key: string) => ReactNode;
18
+ /**
19
+ * The heading for a category key. Absent ⇒ the registry's raw key. The host's,
20
+ * because a mapping declared here goes stale silently on a new tool family.
21
+ */
22
+ readonly categoryLabel?: (category: string) => string;
23
+ }
24
+
25
+ /**
26
+ * The arsenal, or nothing at all.
27
+ *
28
+ * No capability ⇒ absent button, never a disabled one.
29
+ *
30
+ * A pick WRITES an intent into the draft and stops there — it neither runs the
31
+ * tool nor sends the turn, so the user can still add a file or edit the wording.
32
+ */
33
+ export function ComposerTools({ className, children, icon, categoryLabel }: ComposerToolsProps) {
34
+ const { tools } = useCapabilities();
35
+ const copy = useChatCopy().composer;
36
+ const composer = useOptionalComposer();
37
+ const [open, setOpen] = useState(false);
38
+
39
+ const panelRef = useRef<HTMLDivElement>(null);
40
+ const triggerRef = useRef<HTMLButtonElement>(null);
41
+
42
+ const close = useCallback(() => setOpen(false), []);
43
+ // The trigger counts as inside, or its own press dismisses and reopens in one
44
+ // gesture and the sheet never closes by its button.
45
+ useDismiss({ open, onDismiss: close, refs: [panelRef, triggerRef] });
46
+
47
+ // The menu CLOSES on a pick: picking is the common act and unpicking the rare
48
+ // one, so the sheet should get out of the way rather than stay open for a
49
+ // correction most users never make. The staged chips are visible in the tray
50
+ // below, and each carries its own `×` — the way back does not need this sheet.
51
+ const onPick = useCallback(
52
+ (entry: ToolCatalogEntry) => {
53
+ // Staged on the draft, never written into the text: the marker belongs to
54
+ // the whole turn, and splicing one at the caret would change what the
55
+ // prose around it means. `buildOutbound` appends it at send.
56
+ // `needs` rides along so the composer can say what is still missing
57
+ // without holding the catalogue: the draft is what the placeholder reads.
58
+ composer?.toggleTool({
59
+ id: entry.id,
60
+ label: entry.name,
61
+ icon: entry.icon,
62
+ needs: entry.needs,
63
+ });
64
+ // The host's, after the fact — for a menu that must also record the pick.
65
+ // It cannot decide the text, or two writers would own the outbound turn.
66
+ tools?.onPick?.(entry);
67
+ close();
68
+ // A pick states an intent; the sentence naming its object comes next, so
69
+ // the caret must already be waiting. Without this the closing dialog
70
+ // leaves focus nowhere and the next keystroke is lost. Deferred: the menu
71
+ // is still mounted in this tick, and blur on unmount would undo it.
72
+ queueMicrotask(() => focusComposerField(triggerRef.current?.closest("form")));
73
+ },
74
+ [tools, composer, close],
75
+ );
76
+
77
+ const picked = useMemo(
78
+ () => new Set((composer?.draft.pickedTools ?? []).map((pick) => pick.id)),
79
+ [composer?.draft.pickedTools],
80
+ );
81
+
82
+ if (tools === undefined) return null;
83
+
84
+ // Three states, not two. `entries` empty while `loading` means the server has
85
+ // not answered yet, so the trigger holds its place disabled rather than
86
+ // disappearing and popping back when the reply lands.
87
+ const pending = tools.loading === true && tools.entries.length === 0;
88
+
89
+ return (
90
+ <span className="relative inline-flex items-center">
91
+ <ComposerButton
92
+ ref={triggerRef}
93
+ className={className}
94
+ aria-label={copy.toolsMenu}
95
+ aria-expanded={open}
96
+ aria-haspopup="dialog"
97
+ disabled={pending}
98
+ // The label already names the control; `busy` says the wait is this
99
+ // control's own, so a reader is not told the whole composer is loading.
100
+ aria-busy={pending || undefined}
101
+ onClick={() => setOpen((current) => !current)}
102
+ >
103
+ {children}
104
+ </ComposerButton>
105
+ {open && !pending ? (
106
+ <div
107
+ ref={panelRef}
108
+ role="dialog"
109
+ aria-label={copy.toolsMenu}
110
+ /* `left-0`, the mirror of the voice panel's `right-0`: this trigger
111
+ sits at the START of the row, so the panel has room only rightward. */
112
+ className="border-border bg-popover text-popover-foreground absolute bottom-full left-0 z-20 mb-2 w-72 overflow-hidden rounded-lg border shadow-lg"
113
+ >
114
+ <ToolMenu
115
+ entries={tools.entries}
116
+ onPick={onPick}
117
+ picked={picked}
118
+ label={copy.toolsMenu}
119
+ searchLabel={copy.toolsSearch}
120
+ emptyLabel={copy.toolsEmpty}
121
+ recentLabel={copy.toolsRecent}
122
+ forgetLabel={copy.toolsForget}
123
+ icon={icon}
124
+ categoryLabel={categoryLabel}
125
+ />
126
+ </div>
127
+ ) : null}
128
+ </span>
129
+ );
130
+ }
@@ -0,0 +1,10 @@
1
+ export { ChatTooltip as ComposerTooltip } from "../../primitives/tooltip";
2
+ export type { ChatTooltipProps as ComposerTooltipProps } from "../../primitives/tooltip";
3
+
4
+ /**
5
+ * The composer's spelling of the shared `ChatTooltip`.
6
+ *
7
+ * An alias, not a second implementation: the message action row needs the same
8
+ * hover name, so the component moved to `primitives` and this keeps the three
9
+ * composer call sites reading in their own vocabulary.
10
+ */
@@ -0,0 +1,266 @@
1
+ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
2
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
3
+
4
+ import { useChatCopy } from "../../i18n";
5
+ import { useDismiss } from "../../primitives";
6
+ import { useCapabilities, useVoice } from "../../store";
7
+ import { ComposerButton } from "./button";
8
+ import { ComposerTooltip } from "./tooltip";
9
+ import { useComposer } from "../context";
10
+ import { LanguageMenu, languageLabel } from "./language-menu";
11
+
12
+ export interface ComposerVoiceProps {
13
+ readonly className?: string;
14
+ /**
15
+ * Icon or glyph. The accessible name comes from the labels, never from this.
16
+ *
17
+ * A FUNCTION of `recording`, matching `ComposerSend` and `ToolCallHeader`: the
18
+ * glyph must change with the role, and a static node cannot express that.
19
+ *
20
+ * Optional, but every assembly we ship passes one — an omitted `children` is
21
+ * how `ComposerSend` renders a blank blue pill. The default in
22
+ * `ui/chat.tsx` exists so a host never has to think about it.
23
+ */
24
+ readonly children?: (recording: boolean) => ReactNode;
25
+ /**
26
+ * The dictation language and how to change it.
27
+ *
28
+ * Absent ⇒ the mic alone, no options button. A host that has no opinion still
29
+ * gets working dictation, because `useSpeechLanguage` resolves a default;
30
+ * passing this is what makes the resolved value VISIBLE and overridable.
31
+ */
32
+ readonly language?: {
33
+ readonly value: string;
34
+ readonly onChange: (tag: string) => void;
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Dictation, or nothing at all.
40
+ *
41
+ * **No engine ⇒ absent button**, never a disabled one — a greyed-out mic is a
42
+ * promise the product cannot keep. Note the threshold is the ENGINE, not the
43
+ * capability: an absent capability falls back to the package's Web Speech
44
+ * default, so only a browser without one yields no button. A host supplying its
45
+ * own capability overrides that default outright.
46
+ *
47
+ * The ANCHOR lives here, not in `VoiceSession`. The session knows only what was
48
+ * dictated; what the user had already typed before pressing the mic is composer
49
+ * state. Without anchoring, dictating into a half-written message replaces it.
50
+ */
51
+ export function ComposerVoice({ className, children, language }: ComposerVoiceProps) {
52
+ const { voice: capability, voiceSounds } = useCapabilities();
53
+ const copy = useChatCopy().voice;
54
+ const { draft, setText, onSend } = useComposer();
55
+ const { available, recording, text, start, stop, cancel } = useVoice(
56
+ capability,
57
+ language?.value,
58
+ voiceSounds?.play,
59
+ );
60
+ const [pickerOpen, setPickerOpen] = useState(false);
61
+ // Which view the ONE panel is showing. A view switch rather than a second
62
+ // popover: nesting one floating surface inside another makes both fight over
63
+ // the same outside-press, and the inner search box loses keys to the outer.
64
+ const [view, setView] = useState<"main" | "language">("main");
65
+
66
+ const panelRef = useRef<HTMLDivElement>(null);
67
+ const triggerRef = useRef<HTMLButtonElement>(null);
68
+
69
+ const close = useCallback(() => {
70
+ setPickerOpen(false);
71
+ // Reset on the way out, so reopening always starts at the top rather than
72
+ // wherever the last visit ended.
73
+ setView("main");
74
+ }, []);
75
+
76
+ // The trigger is `inside` too — otherwise its own press dismisses and
77
+ // reopens in one gesture, and the panel can never be closed by its button.
78
+ useDismiss({ open: pickerOpen, onDismiss: close, refs: [panelRef, triggerRef] });
79
+
80
+ // Text present when dictation began. A ref, not state: it is read in a
81
+ // handler and an effect, never rendered.
82
+ const anchor = useRef("");
83
+ // Whether THIS session's transcript may write to the draft. Set on press,
84
+ // cleared on the commit, so a settled session cannot keep re-anchoring — see
85
+ // the effect below for the duplication that caused.
86
+ const armed = useRef(false);
87
+ // The draft, readable without depending on it. The click handler needs the
88
+ // CURRENT text, but this button has no reason to re-render on every keystroke.
89
+ const draftText = useRef(draft.text);
90
+ draftText.current = draft.text;
91
+
92
+ // A send ends dictation and discards what it heard.
93
+ //
94
+ // `cancel`, never `stop`: stop commits the in-flight interim into a draft the
95
+ // send just cleared, so the sent words reappear in the empty field.
96
+ //
97
+ // `armed` is disarmed here because it lives in this component, not the
98
+ // session: cancel emits one last empty snapshot that an armed effect would
99
+ // write into the new draft.
100
+ //
101
+ // Registered unconditionally — gating on `recording` would re-subscribe once
102
+ // per interim result, and cancel on an idle session is a no-op.
103
+ useEffect(() => {
104
+ return onSend(() => {
105
+ armed.current = false;
106
+ anchor.current = "";
107
+ cancel();
108
+ });
109
+ }, [onSend, cancel]);
110
+
111
+ const onClick = useCallback(() => {
112
+ if (recording) {
113
+ stop();
114
+ return;
115
+ }
116
+ // Captured on the way IN, before the session emits anything, so the anchor
117
+ // is the user's typed text and never a previous transcript.
118
+ anchor.current = draftText.current;
119
+ armed.current = true;
120
+ start();
121
+ }, [recording, start, stop]);
122
+
123
+ // Push the transcript into the draft as it arrives. Depends on `text`, so an
124
+ // interim REPLACING its predecessor replaces it in the field too — the
125
+ // duplicate-sentence bug `VoiceSession` exists to prevent, preserved across
126
+ // the React boundary rather than re-solved here.
127
+ //
128
+ // Gated on `armed`, NOT on `text !== ""`. The empty-text guard looks
129
+ // equivalent and is not: `start()` emits an empty snapshot, so a settled
130
+ // session's transcript could be re-applied by a later unrelated render, on top
131
+ // of whatever the user had typed since.
132
+ //
133
+ // What this does NOT do is stop a second dictation appending to the first —
134
+ // that is correct behaviour, not duplication. A user who presses the mic twice
135
+ // dictated twice, and the anchor is the draft as it stands, first transcript
136
+ // included.
137
+ useEffect(() => {
138
+ if (!armed.current) return;
139
+ setText(anchor.current === "" ? text : `${anchor.current} ${text}`);
140
+ // Disarm once the session has settled and delivered its final text. Leaving
141
+ // it armed would let a later unrelated render re-apply the same transcript
142
+ // on top of whatever the user has typed since.
143
+ if (!recording && text !== "") armed.current = false;
144
+ }, [recording, text, setText]);
145
+
146
+ if (!available) return null;
147
+
148
+ const micLabel = recording ? copy.stop : copy.start;
149
+ const mic = (
150
+ <ComposerTooltip label={micLabel}>
151
+ <ComposerButton
152
+ className={className}
153
+ aria-label={micLabel}
154
+ // A toggle, so the state is announced rather than inferred from the icon.
155
+ aria-pressed={recording}
156
+ // The state as DATA, so `styles/composer.css` owns the halo and its
157
+ // reduced-motion fallback. A Tailwind arbitrary variant would need the
158
+ // keyframes declared somewhere anyway, and then the rule and its
159
+ // animation would live in two files.
160
+ data-recording={recording || undefined}
161
+ onClick={onClick}
162
+ >
163
+ {children?.(recording)}
164
+ </ComposerButton>
165
+ </ComposerTooltip>
166
+ );
167
+
168
+ if (language === undefined) return mic;
169
+
170
+ const back = (
171
+ <div className="border-border flex items-center gap-1 border-b px-1 py-1">
172
+ <button
173
+ type="button"
174
+ aria-label={copy.languageBack}
175
+ onClick={() => setView("main")}
176
+ className="text-muted-foreground hover:bg-accent hover:text-foreground inline-flex h-6 w-6 items-center justify-center rounded-sm"
177
+ >
178
+ <ChevronLeft className="size-4" aria-hidden />
179
+ </button>
180
+ <span className="text-sm font-medium">{copy.languageMenu}</span>
181
+ </div>
182
+ );
183
+
184
+ const panel =
185
+ view === "language" ? (
186
+ <LanguageMenu
187
+ current={language.value}
188
+ onSelect={(tag) => {
189
+ language.onChange(tag);
190
+ close();
191
+ }}
192
+ label={copy.languageMenu}
193
+ searchLabel={copy.languageSearch}
194
+ emptyLabel={copy.languageEmpty}
195
+ header={back}
196
+ />
197
+ ) : (
198
+ <div className="flex flex-col gap-0.5 p-1">
199
+ {/*
200
+ The current value is on the ROW, not only behind it. A user whose
201
+ dictation returns nonsense has to be able to read what it is listening
202
+ for at the point where it fails — otherwise the state that broke the
203
+ feature is invisible from the feature.
204
+ */}
205
+ <button
206
+ type="button"
207
+ onClick={() => setView("language")}
208
+ className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm"
209
+ >
210
+ <span className="flex min-w-0 flex-1 flex-col">
211
+ <span>{copy.languageMenu}</span>
212
+ <span className="text-muted-foreground truncate text-xs">
213
+ {languageLabel(language.value)}
214
+ </span>
215
+ </span>
216
+ <ChevronRight className="text-muted-foreground size-4 shrink-0" aria-hidden />
217
+ </button>
218
+ </div>
219
+ );
220
+
221
+ // Trigger LEFT of the mic, mic last. The mic is the control the user aims for
222
+ // and the one that must stay adjacent to Send; an options button between them
223
+ // makes the action bar read as two competing primary buttons.
224
+ return (
225
+ <span className="relative inline-flex items-center gap-1">
226
+ {/* The shared control, like every other button in this row. Hand-rolled it
227
+ tinted with `--accent` — the theme's highlight, blue here — so this one
228
+ answered a hover with a coloured plate while its neighbours answered
229
+ with a grey one, and the row read as four unrelated controls. */}
230
+ {/* Tooltip OUTER, so its `asChild` and the button's ref compose onto one
231
+ node rather than each cloning separately. */}
232
+ <ComposerTooltip label={copy.language}>
233
+ <ComposerButton
234
+ ref={triggerRef}
235
+ aria-label={copy.language}
236
+ aria-expanded={pickerOpen}
237
+ aria-haspopup="dialog"
238
+ onClick={() => {
239
+ setPickerOpen((open) => !open);
240
+ setView("main");
241
+ }}
242
+ >
243
+ <ChevronDown className="size-4" aria-hidden />
244
+ </ComposerButton>
245
+ </ComposerTooltip>
246
+ {pickerOpen ? (
247
+ <div
248
+ ref={panelRef}
249
+ role="dialog"
250
+ aria-label={copy.language}
251
+ /*
252
+ Anchored to the trigger's RIGHT edge, not its left. The panel is
253
+ wider than the control that opens it, so `left-0` sends 256px of it
254
+ off past the composer's right edge — measured as a menu floating
255
+ detached in the middle of the transcript. `right-0` grows it back
256
+ over the composer, which is the only direction with room.
257
+ */
258
+ className="border-border bg-popover text-popover-foreground absolute bottom-full right-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border shadow-lg"
259
+ >
260
+ {panel}
261
+ </div>
262
+ ) : null}
263
+ {mic}
264
+ </span>
265
+ );
266
+ }
@@ -0,0 +1,140 @@
1
+ import { useRef, type KeyboardEvent } from "react";
2
+ import { useChatEvents, useRecall } from "../../store";
3
+ import { unmetNeed } from "../../core";
4
+ import { useChatCopy } from "../../i18n";
5
+ import { composerKeyDown } from "./keys";
6
+ import { ComposerInput } from "./input";
7
+ import { useComposer } from "../context";
8
+
9
+ export interface ComposerFieldProps {
10
+ readonly className?: string;
11
+ /**
12
+ * Classes for the POSITIONED WRAPPER, not the textarea.
13
+ *
14
+ * They are different elements with different jobs, and only the wrapper is
15
+ * laid out by the composer's flex row — the textarea inside it is an ordinary
16
+ * block. Anything about width, growth or wrapping has to land here or it
17
+ * resolves against nothing.
18
+ */
19
+ readonly wrapperClassName?: string;
20
+ /** Sent prompts for ↑ recall, OLDEST first. Empty ⇒ the arrows never navigate. */
21
+ readonly history: readonly string[];
22
+ }
23
+
24
+ /**
25
+ * The composer field: the textarea and ↑/↓ recall over it.
26
+ *
27
+ * Both here because recall needs the field's ONE DOM node — it reads where the
28
+ * caret sits before it will claim an arrow. The MIC is the exception: it is a
29
+ * sibling control, and leaving it to the host is what keeps the row's order
30
+ * (field → mic → send) readable in one place.
31
+ *
32
+ * `ComposerInput` stays the lower seam and knows nothing about ladders, which is
33
+ * what keeps the editor swappable without retesting the racy half in
34
+ * `core/recall`.
35
+ */
36
+ export function ComposerField({ className, wrapperClassName, history }: ComposerFieldProps) {
37
+ const composer = useComposer();
38
+ const events = useChatEvents();
39
+ const copy = useChatCopy().composer;
40
+ // A ref, not state: nothing renders from the node. It was state while an
41
+ // effect had to re-run once the node arrived — the vendor dictation bridge —
42
+ // and holding it as state now would re-render the field for nobody. `useRecall`
43
+ // wants the ref anyway: it reads the caret synchronously inside a keydown
44
+ // handler, where a state value is one render stale.
45
+ const nodeRef = useRef<HTMLTextAreaElement | null>(null);
46
+
47
+ const recall = useRecall({
48
+ entries: history,
49
+ input: nodeRef,
50
+ draft: composer.draft,
51
+ setText: composer.setText,
52
+ });
53
+
54
+ /*
55
+ A picked tool that still lacks its object REPLACES the standing advice.
56
+ "Ask anything" is exactly the line that stops being true once the user has
57
+ named an act, and a second line would turn one box into two.
58
+ */
59
+ const needed = unmetNeed(composer.draft);
60
+ const placeholder =
61
+ needed === "file" ? copy.needsFile : needed === "skill" ? copy.needsSkill : copy.placeholder;
62
+
63
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) =>
64
+ composerKeyDown(event, {
65
+ recall,
66
+ draftText: composer.draft.text,
67
+ // The whole draft, not its text: what went out is a draft, and narrowing
68
+ // it here would decide for every listener which of its fields matter.
69
+ onSending: () => events.emit("turn:sent", { draft: composer.draft }),
70
+ });
71
+
72
+ // `flex` rather than the default block. A block box puts its inline textarea
73
+ // on a TEXT BASELINE, which reserves descender space beneath it — measured
74
+ // 5px, making the wrapper 37px around a 32px field. The composer row aligns
75
+ // `items-end`, so it aligned that phantom gap and the text floated visibly
76
+ // above the buttons beside it.
77
+ return (
78
+ <div className={`relative flex min-w-0 flex-1 ${wrapperClassName ?? ""}`}>
79
+ <ComposerInput
80
+ ref={nodeRef}
81
+ label={copy.input}
82
+ /*
83
+ No placeholder while previewing. Both draw into the same empty field,
84
+ so they render ON TOP of each other — the preview and "type a
85
+ message…" overlapped into unreadable mush, which every DOM probe
86
+ reported as two correct elements and only a screenshot showed.
87
+
88
+ The preview wins because it is the live answer to what Tab does; the
89
+ placeholder is standing advice, and it returns the moment the walk ends.
90
+ */
91
+ placeholder={recall.preview === null ? placeholder : undefined}
92
+ className={className}
93
+ onKeyDown={onKeyDown}
94
+ />
95
+ {recall.preview === null ? null : (
96
+ /*
97
+ The recalled prompt, over the empty field rather than beside it.
98
+
99
+ Overlaid because recall only walks on an EMPTY buffer, so the line is
100
+ free — and because a preview in its own row would change the
101
+ composer's height on every press, which is the reflow this whole
102
+ design exists to remove.
103
+
104
+ `pointer-events-none` so the text underneath still takes the click:
105
+ the preview is something to read, not something to hit. The chip names
106
+ the key that commits; it is a LABEL, and the keyboard is the path.
107
+ */
108
+ <div
109
+ /*
110
+ Announced, not hidden. The preview is the ONLY signal of what Tab
111
+ will insert, so a screen reader user needs it more than a sighted
112
+ one, who can at least see the line. `polite` because it changes on
113
+ every arrow and must not interrupt.
114
+ */
115
+ role="status"
116
+ aria-live="polite"
117
+ className="pointer-events-none absolute inset-0 flex items-center gap-2 py-1"
118
+ >
119
+ {/*
120
+ `max-w-full` with no `flex-1`: the chip must sit BESIDE the text it
121
+ labels, not at the far edge of the field. A growing span pushed it
122
+ to the right margin, where it read as an unrelated control.
123
+ */}
124
+ <span className="text-muted-foreground min-w-0 max-w-full truncate leading-6">
125
+ {recall.preview}
126
+ </span>
127
+ {/*
128
+ The key's own name, not translated: `Tab` is what is printed on the
129
+ keycap in every locale this ships in, and translating it would name
130
+ a key the user cannot find. The surrounding `role="status"` carries
131
+ the meaning for a screen reader.
132
+ */}
133
+ <span className="rounded border border-border px-1 text-[10px] uppercase text-muted-foreground">
134
+ tab
135
+ </span>
136
+ </div>
137
+ )}
138
+ </div>
139
+ );
140
+ }