@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- 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
|
+
}
|