@runtypelabs/persona 3.17.0 → 3.19.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/README.md +143 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
- package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -4
- package/dist/index.d.ts +580 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +2844 -752
- package/dist/theme-editor.d.cts +337 -1
- package/dist/theme-editor.d.ts +337 -1
- package/dist/theme-editor.js +2958 -752
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +780 -0
- package/package.json +1 -1
- package/src/client.test.ts +134 -0
- package/src/client.ts +71 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/messages.ts +33 -1
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +303 -9
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/defaults.ts +21 -0
- package/src/index.ts +20 -1
- package/src/plugins/types.ts +57 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +780 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +271 -1
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +1439 -76
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.ts +7 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildComposer } from "./composer-builder";
|
|
5
|
+
import type { AgentWidgetConfig } from "../types";
|
|
6
|
+
|
|
7
|
+
describe("buildComposer (full column-stacked composer)", () => {
|
|
8
|
+
it("returns the full ComposerElements shape with stable selectors", () => {
|
|
9
|
+
const config: AgentWidgetConfig = {
|
|
10
|
+
apiUrl: "/api",
|
|
11
|
+
voiceRecognition: { enabled: true, provider: { type: "runtype" } },
|
|
12
|
+
attachments: { enabled: true },
|
|
13
|
+
};
|
|
14
|
+
const elements = buildComposer({ config });
|
|
15
|
+
|
|
16
|
+
expect(elements.footer.classList.contains("persona-widget-footer")).toBe(true);
|
|
17
|
+
expect(elements.composerForm.tagName).toBe("FORM");
|
|
18
|
+
expect(elements.composerForm.getAttribute("data-persona-composer-form")).toBe("");
|
|
19
|
+
expect(elements.composerForm.classList.contains("persona-flex-col")).toBe(true);
|
|
20
|
+
|
|
21
|
+
expect(elements.textarea.getAttribute("data-persona-composer-input")).toBe("");
|
|
22
|
+
expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
|
|
23
|
+
expect(elements.statusText.getAttribute("data-persona-composer-status")).toBe("");
|
|
24
|
+
|
|
25
|
+
expect(elements.attachmentButton).not.toBeNull();
|
|
26
|
+
expect(elements.attachmentInput).not.toBeNull();
|
|
27
|
+
expect(elements.attachmentPreviewsContainer).not.toBeNull();
|
|
28
|
+
expect(elements.micButton).not.toBeNull();
|
|
29
|
+
|
|
30
|
+
expect(elements.actionsRow.classList.contains("persona-widget-composer__actions")).toBe(true);
|
|
31
|
+
expect(elements.leftActions.classList.contains("persona-widget-composer__left-actions")).toBe(true);
|
|
32
|
+
expect(elements.rightActions.classList.contains("persona-widget-composer__right-actions")).toBe(true);
|
|
33
|
+
|
|
34
|
+
expect(typeof elements.setSendButtonMode).toBe("function");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null for optional controls when their features are disabled", () => {
|
|
38
|
+
const elements = buildComposer({ config: { apiUrl: "/api" } });
|
|
39
|
+
expect(elements.micButton).toBeNull();
|
|
40
|
+
expect(elements.micButtonWrapper).toBeNull();
|
|
41
|
+
expect(elements.attachmentButton).toBeNull();
|
|
42
|
+
expect(elements.attachmentInput).toBeNull();
|
|
43
|
+
expect(elements.attachmentPreviewsContainer).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("attaches the suggestions row, composer form, and status text to the footer in order", () => {
|
|
47
|
+
const elements = buildComposer({ config: { apiUrl: "/api" } });
|
|
48
|
+
expect(elements.footer.children[0]).toBe(elements.suggestions);
|
|
49
|
+
expect(elements.footer.children[1]).toBe(elements.composerForm);
|
|
50
|
+
expect(elements.footer.children[2]).toBe(elements.statusText);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { createElement } from "../utils/dom";
|
|
2
|
-
import { renderLucideIcon } from "../utils/icons";
|
|
3
2
|
import { AgentWidgetConfig, ContentPart } from "../types";
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createAttachmentControls,
|
|
5
|
+
createComposerTextarea,
|
|
6
|
+
createMicButton,
|
|
7
|
+
createSendButton,
|
|
8
|
+
createStatusText,
|
|
9
|
+
createSuggestionsRow,
|
|
10
|
+
} from "./composer-parts";
|
|
5
11
|
|
|
6
12
|
export interface ComposerElements {
|
|
7
13
|
footer: HTMLElement;
|
|
@@ -13,12 +19,10 @@ export interface ComposerElements {
|
|
|
13
19
|
micButton: HTMLButtonElement | null;
|
|
14
20
|
micButtonWrapper: HTMLElement | null;
|
|
15
21
|
statusText: HTMLElement;
|
|
16
|
-
// Attachment elements
|
|
17
22
|
attachmentButton: HTMLButtonElement | null;
|
|
18
23
|
attachmentButtonWrapper: HTMLElement | null;
|
|
19
24
|
attachmentInput: HTMLInputElement | null;
|
|
20
25
|
attachmentPreviewsContainer: HTMLElement | null;
|
|
21
|
-
// Actions row layout elements
|
|
22
26
|
actionsRow: HTMLElement;
|
|
23
27
|
leftActions: HTMLElement;
|
|
24
28
|
rightActions: HTMLElement;
|
|
@@ -31,9 +35,6 @@ export interface ComposerElements {
|
|
|
31
35
|
setSendButtonMode: (mode: "send" | "stop") => void;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
/**
|
|
35
|
-
* Pending attachment before it's added to the message
|
|
36
|
-
*/
|
|
37
38
|
export interface PendingAttachment {
|
|
38
39
|
id: string;
|
|
39
40
|
file: File;
|
|
@@ -48,8 +49,10 @@ export interface ComposerBuildContext {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
|
-
* Build the
|
|
52
|
-
*
|
|
52
|
+
* Build the full footer + composer form (column-stacked card layout) for
|
|
53
|
+
* the floating, docked, and inline-embed launcher modes. The pill variant
|
|
54
|
+
* for `mountMode: "composer-bar"` lives in `pill-composer-builder.ts` and
|
|
55
|
+
* shares the same low-level part factories from `composer-parts.ts`.
|
|
53
56
|
*/
|
|
54
57
|
export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
|
|
55
58
|
const { config } = context;
|
|
@@ -60,491 +63,69 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
60
63
|
);
|
|
61
64
|
footer.setAttribute("data-persona-theme-zone", "composer");
|
|
62
65
|
|
|
63
|
-
const suggestions =
|
|
64
|
-
"div",
|
|
65
|
-
"persona-mb-3 persona-flex persona-flex-wrap persona-gap-2"
|
|
66
|
-
);
|
|
66
|
+
const suggestions = createSuggestionsRow();
|
|
67
67
|
|
|
68
|
-
// Composer form uses column layout: textarea on top, actions row below
|
|
69
68
|
const composerForm = createElement(
|
|
70
69
|
"form",
|
|
71
|
-
|
|
70
|
+
"persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3"
|
|
72
71
|
) as HTMLFormElement;
|
|
73
72
|
composerForm.setAttribute("data-persona-composer-form", "");
|
|
74
|
-
// Prevent form from getting focus styles
|
|
75
73
|
composerForm.style.outline = "none";
|
|
76
74
|
|
|
77
|
-
const textarea =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const maxHeight = maxLines * lineHeight;
|
|
93
|
-
textarea.style.maxHeight = `${maxHeight}px`;
|
|
94
|
-
textarea.style.overflowY = "auto";
|
|
95
|
-
|
|
96
|
-
// Auto-resize function
|
|
97
|
-
const autoResize = () => {
|
|
98
|
-
// Reset height to auto to get the correct scrollHeight
|
|
99
|
-
textarea.style.height = "auto";
|
|
100
|
-
// Set height to scrollHeight (capped by maxHeight via CSS)
|
|
101
|
-
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
102
|
-
textarea.style.height = `${newHeight}px`;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// Listen for input to auto-resize
|
|
106
|
-
textarea.addEventListener("input", autoResize);
|
|
107
|
-
|
|
108
|
-
// Explicitly remove border and outline on focus to prevent browser defaults
|
|
109
|
-
textarea.style.border = "none";
|
|
110
|
-
textarea.style.outline = "none";
|
|
111
|
-
textarea.style.borderWidth = "0";
|
|
112
|
-
textarea.style.borderStyle = "none";
|
|
113
|
-
textarea.style.borderColor = "transparent";
|
|
114
|
-
textarea.addEventListener("focus", () => {
|
|
115
|
-
textarea.style.border = "none";
|
|
116
|
-
textarea.style.outline = "none";
|
|
117
|
-
textarea.style.borderWidth = "0";
|
|
118
|
-
textarea.style.borderStyle = "none";
|
|
119
|
-
textarea.style.borderColor = "transparent";
|
|
120
|
-
textarea.style.boxShadow = "none";
|
|
121
|
-
});
|
|
122
|
-
textarea.addEventListener("blur", () => {
|
|
123
|
-
textarea.style.border = "none";
|
|
124
|
-
textarea.style.outline = "none";
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// Send button configuration
|
|
128
|
-
const sendButtonConfig = config?.sendButton ?? {};
|
|
129
|
-
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
130
|
-
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
131
|
-
const iconName = sendButtonConfig.iconName;
|
|
132
|
-
const stopIconName = sendButtonConfig.stopIconName ?? "square";
|
|
133
|
-
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
134
|
-
const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
|
|
135
|
-
const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
|
|
136
|
-
const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
|
|
137
|
-
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
138
|
-
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
139
|
-
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
140
|
-
const textColor = sendButtonConfig.textColor;
|
|
141
|
-
|
|
142
|
-
// Create wrapper for tooltip positioning
|
|
143
|
-
const sendButtonWrapper = createElement("div", "persona-send-button-wrapper");
|
|
144
|
-
|
|
145
|
-
const sendButton = createElement(
|
|
146
|
-
"button",
|
|
147
|
-
useIcon
|
|
148
|
-
? "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
|
|
149
|
-
: "persona-rounded-button persona-bg-persona-accent persona-px-4 persona-py-2 persona-text-sm persona-font-semibold disabled:persona-opacity-50 persona-cursor-pointer"
|
|
150
|
-
) as HTMLButtonElement;
|
|
151
|
-
|
|
152
|
-
sendButton.type = "submit";
|
|
153
|
-
sendButton.setAttribute("data-persona-composer-submit", "");
|
|
154
|
-
|
|
155
|
-
// Icons for both modes are pre-rendered so setSendButtonMode can swap them
|
|
156
|
-
// without having to re-render on every streaming state change.
|
|
157
|
-
let sendIcon: SVGElement | null = null;
|
|
158
|
-
let stopIcon: SVGElement | null = null;
|
|
159
|
-
|
|
160
|
-
if (useIcon) {
|
|
161
|
-
// Icon mode: circular button
|
|
162
|
-
sendButton.style.width = buttonSize;
|
|
163
|
-
sendButton.style.height = buttonSize;
|
|
164
|
-
sendButton.style.minWidth = buttonSize;
|
|
165
|
-
sendButton.style.minHeight = buttonSize;
|
|
166
|
-
sendButton.style.fontSize = "18px";
|
|
167
|
-
sendButton.style.lineHeight = "1";
|
|
168
|
-
|
|
169
|
-
// Clear any existing content
|
|
170
|
-
sendButton.innerHTML = "";
|
|
171
|
-
|
|
172
|
-
// Set button foreground color from config or theme token
|
|
173
|
-
if (textColor) {
|
|
174
|
-
sendButton.style.color = textColor;
|
|
175
|
-
} else {
|
|
176
|
-
sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const iconSize = parseFloat(buttonSize) || 24;
|
|
180
|
-
const iconColor = textColor?.trim() || "currentColor";
|
|
181
|
-
|
|
182
|
-
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
183
|
-
if (iconName) {
|
|
184
|
-
sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
185
|
-
if (sendIcon) {
|
|
186
|
-
sendButton.appendChild(sendIcon);
|
|
187
|
-
} else {
|
|
188
|
-
sendButton.textContent = iconText;
|
|
189
|
-
}
|
|
190
|
-
} else {
|
|
191
|
-
sendButton.textContent = iconText;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Pre-render the stop icon so mode swaps are cheap; it starts detached.
|
|
195
|
-
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
|
|
196
|
-
|
|
197
|
-
if (backgroundColor) {
|
|
198
|
-
sendButton.style.backgroundColor = backgroundColor;
|
|
199
|
-
} else {
|
|
200
|
-
sendButton.classList.add("persona-bg-persona-primary");
|
|
201
|
-
}
|
|
202
|
-
} else {
|
|
203
|
-
// Text mode: existing behavior
|
|
204
|
-
sendButton.textContent = sendLabel;
|
|
205
|
-
if (textColor) {
|
|
206
|
-
sendButton.style.color = textColor;
|
|
207
|
-
} else {
|
|
208
|
-
sendButton.classList.add("persona-text-white");
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Apply existing styling from config
|
|
213
|
-
if (sendButtonConfig.borderWidth) {
|
|
214
|
-
sendButton.style.borderWidth = sendButtonConfig.borderWidth;
|
|
215
|
-
sendButton.style.borderStyle = "solid";
|
|
216
|
-
}
|
|
217
|
-
if (sendButtonConfig.borderColor) {
|
|
218
|
-
sendButton.style.borderColor = sendButtonConfig.borderColor;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Apply padding styling (works in both icon and text mode)
|
|
222
|
-
if (sendButtonConfig.paddingX) {
|
|
223
|
-
sendButton.style.paddingLeft = sendButtonConfig.paddingX;
|
|
224
|
-
sendButton.style.paddingRight = sendButtonConfig.paddingX;
|
|
225
|
-
} else {
|
|
226
|
-
sendButton.style.paddingLeft = "";
|
|
227
|
-
sendButton.style.paddingRight = "";
|
|
228
|
-
}
|
|
229
|
-
if (sendButtonConfig.paddingY) {
|
|
230
|
-
sendButton.style.paddingTop = sendButtonConfig.paddingY;
|
|
231
|
-
sendButton.style.paddingBottom = sendButtonConfig.paddingY;
|
|
232
|
-
} else {
|
|
233
|
-
sendButton.style.paddingTop = "";
|
|
234
|
-
sendButton.style.paddingBottom = "";
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Add tooltip if enabled
|
|
238
|
-
let sendTooltip: HTMLElement | null = null;
|
|
239
|
-
if (showTooltip && tooltipText) {
|
|
240
|
-
sendTooltip = createElement("div", "persona-send-button-tooltip");
|
|
241
|
-
sendTooltip.textContent = tooltipText;
|
|
242
|
-
sendButtonWrapper.appendChild(sendTooltip);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
sendButton.setAttribute("aria-label", tooltipText);
|
|
246
|
-
|
|
247
|
-
sendButtonWrapper.appendChild(sendButton);
|
|
248
|
-
|
|
249
|
-
let currentMode: "send" | "stop" = "send";
|
|
250
|
-
const setSendButtonMode = (mode: "send" | "stop") => {
|
|
251
|
-
if (mode === currentMode) return;
|
|
252
|
-
currentMode = mode;
|
|
253
|
-
const label = mode === "stop" ? stopTooltipText : tooltipText;
|
|
254
|
-
sendButton.setAttribute("aria-label", label);
|
|
255
|
-
if (sendTooltip) {
|
|
256
|
-
sendTooltip.textContent = label;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (useIcon) {
|
|
260
|
-
// Only swap icons if both were rendered successfully; otherwise the
|
|
261
|
-
// button is using textContent fallback and there's nothing to swap.
|
|
262
|
-
if (sendIcon && stopIcon) {
|
|
263
|
-
const next = mode === "stop" ? stopIcon : sendIcon;
|
|
264
|
-
const prev = mode === "stop" ? sendIcon : stopIcon;
|
|
265
|
-
if (prev.parentNode === sendButton) {
|
|
266
|
-
sendButton.replaceChild(next, prev);
|
|
267
|
-
} else {
|
|
268
|
-
sendButton.appendChild(next);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
272
|
-
sendButton.textContent = mode === "stop" ? stopLabel : sendLabel;
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
// Voice recognition mic button
|
|
277
|
-
const voiceRecognitionConfig = config?.voiceRecognition ?? {};
|
|
278
|
-
const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
|
|
279
|
-
let micButton: HTMLButtonElement | null = null;
|
|
280
|
-
let micButtonWrapper: HTMLElement | null = null;
|
|
281
|
-
|
|
282
|
-
// Check browser support for speech recognition or Runtype provider
|
|
283
|
-
const hasSpeechRecognition =
|
|
284
|
-
typeof window !== "undefined" &&
|
|
285
|
-
(typeof (window as any).webkitSpeechRecognition !== "undefined" ||
|
|
286
|
-
typeof (window as any).SpeechRecognition !== "undefined");
|
|
287
|
-
const hasRuntypeProvider =
|
|
288
|
-
voiceRecognitionConfig.provider?.type === "runtype";
|
|
289
|
-
const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider;
|
|
290
|
-
|
|
291
|
-
if (voiceRecognitionEnabled && hasVoiceInput) {
|
|
292
|
-
micButtonWrapper = createElement("div", "persona-send-button-wrapper");
|
|
293
|
-
micButton = createElement(
|
|
294
|
-
"button",
|
|
295
|
-
"persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
|
|
296
|
-
) as HTMLButtonElement;
|
|
297
|
-
|
|
298
|
-
micButton.type = "button";
|
|
299
|
-
micButton.setAttribute("data-persona-composer-mic", "");
|
|
300
|
-
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
301
|
-
|
|
302
|
-
const micIconName = voiceRecognitionConfig.iconName ?? "mic";
|
|
303
|
-
const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
|
|
304
|
-
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
305
|
-
|
|
306
|
-
// Use dedicated colors from voice recognition config, fallback to send button colors
|
|
307
|
-
const micBackgroundColor =
|
|
308
|
-
voiceRecognitionConfig.backgroundColor ?? backgroundColor;
|
|
309
|
-
const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
|
|
310
|
-
|
|
311
|
-
micButton.style.width = micIconSize;
|
|
312
|
-
micButton.style.height = micIconSize;
|
|
313
|
-
micButton.style.minWidth = micIconSize;
|
|
314
|
-
micButton.style.minHeight = micIconSize;
|
|
315
|
-
micButton.style.fontSize = "18px";
|
|
316
|
-
micButton.style.lineHeight = "1";
|
|
317
|
-
|
|
318
|
-
// Set mic button foreground from config or theme token
|
|
319
|
-
if (micIconColor) {
|
|
320
|
-
micButton.style.color = micIconColor;
|
|
321
|
-
} else {
|
|
322
|
-
micButton.style.color = "var(--persona-text, #111827)";
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Use Lucide mic icon (stroke width 1.5 for minimalist outline style)
|
|
326
|
-
const iconColorValue = micIconColor || "currentColor";
|
|
327
|
-
const micIconSvg = renderLucideIcon(
|
|
328
|
-
micIconName,
|
|
329
|
-
micIconSizeNum,
|
|
330
|
-
iconColorValue,
|
|
331
|
-
1.5
|
|
332
|
-
);
|
|
333
|
-
if (micIconSvg) {
|
|
334
|
-
micButton.appendChild(micIconSvg);
|
|
335
|
-
} else {
|
|
336
|
-
micButton.textContent = "🎤";
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Apply background color
|
|
340
|
-
if (micBackgroundColor) {
|
|
341
|
-
micButton.style.backgroundColor = micBackgroundColor;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Apply border styling
|
|
345
|
-
if (voiceRecognitionConfig.borderWidth) {
|
|
346
|
-
micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
|
|
347
|
-
micButton.style.borderStyle = "solid";
|
|
348
|
-
}
|
|
349
|
-
if (voiceRecognitionConfig.borderColor) {
|
|
350
|
-
micButton.style.borderColor = voiceRecognitionConfig.borderColor;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Apply padding styling
|
|
354
|
-
if (voiceRecognitionConfig.paddingX) {
|
|
355
|
-
micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
|
|
356
|
-
micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
|
|
357
|
-
}
|
|
358
|
-
if (voiceRecognitionConfig.paddingY) {
|
|
359
|
-
micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
|
|
360
|
-
micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
micButtonWrapper.appendChild(micButton);
|
|
364
|
-
|
|
365
|
-
// Add tooltip if enabled
|
|
366
|
-
const micTooltipText =
|
|
367
|
-
voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
|
|
368
|
-
const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
|
|
369
|
-
if (showMicTooltip && micTooltipText) {
|
|
370
|
-
const tooltip = createElement("div", "persona-send-button-tooltip");
|
|
371
|
-
tooltip.textContent = micTooltipText;
|
|
372
|
-
micButtonWrapper.appendChild(tooltip);
|
|
373
|
-
}
|
|
75
|
+
const { textarea, attachAutoResize } = createComposerTextarea(config);
|
|
76
|
+
attachAutoResize();
|
|
77
|
+
|
|
78
|
+
const send = createSendButton(config);
|
|
79
|
+
const mic = createMicButton(config);
|
|
80
|
+
const attachment = createAttachmentControls(config);
|
|
81
|
+
const statusText = createStatusText(config);
|
|
82
|
+
|
|
83
|
+
// Layout (column):
|
|
84
|
+
// row 1: attachment previews (above textarea, smaller)
|
|
85
|
+
// row 2: textarea (full width)
|
|
86
|
+
// row 3: actions (paperclip left, mic + send right)
|
|
87
|
+
if (attachment) {
|
|
88
|
+
attachment.previewsContainer.style.gap = "8px";
|
|
89
|
+
composerForm.append(attachment.previewsContainer, attachment.input);
|
|
374
90
|
}
|
|
91
|
+
composerForm.append(textarea);
|
|
375
92
|
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
|
|
397
|
-
attachmentInput.style.display = "none";
|
|
398
|
-
attachmentInput.setAttribute("aria-label", "Attach files");
|
|
399
|
-
|
|
400
|
-
// Create attachment button wrapper for tooltip
|
|
401
|
-
attachmentButtonWrapper = createElement("div", "persona-send-button-wrapper");
|
|
402
|
-
|
|
403
|
-
// Create attachment button
|
|
404
|
-
attachmentButton = createElement(
|
|
405
|
-
"button",
|
|
406
|
-
"persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button"
|
|
407
|
-
) as HTMLButtonElement;
|
|
408
|
-
attachmentButton.type = "button";
|
|
409
|
-
attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
|
|
410
|
-
|
|
411
|
-
// Default to paperclip icon
|
|
412
|
-
const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
|
|
413
|
-
const attachIconSize = buttonSize;
|
|
414
|
-
const buttonSizeNum = parseFloat(attachIconSize) || 40;
|
|
415
|
-
// Icon should be ~60% of button size to match other icons visually
|
|
416
|
-
const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
|
|
417
|
-
|
|
418
|
-
attachmentButton.style.width = attachIconSize;
|
|
419
|
-
attachmentButton.style.height = attachIconSize;
|
|
420
|
-
attachmentButton.style.minWidth = attachIconSize;
|
|
421
|
-
attachmentButton.style.minHeight = attachIconSize;
|
|
422
|
-
attachmentButton.style.fontSize = "18px";
|
|
423
|
-
attachmentButton.style.lineHeight = "1";
|
|
424
|
-
attachmentButton.style.backgroundColor = "transparent";
|
|
425
|
-
attachmentButton.style.color = "var(--persona-primary, #111827)";
|
|
426
|
-
attachmentButton.style.border = "none";
|
|
427
|
-
attachmentButton.style.borderRadius = "6px";
|
|
428
|
-
attachmentButton.style.transition = "background-color 0.15s ease";
|
|
429
|
-
|
|
430
|
-
// Add hover effect via mouseenter/mouseleave
|
|
431
|
-
attachmentButton.addEventListener("mouseenter", () => {
|
|
432
|
-
attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
|
|
433
|
-
});
|
|
434
|
-
attachmentButton.addEventListener("mouseleave", () => {
|
|
435
|
-
attachmentButton!.style.backgroundColor = "transparent";
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// Render the icon
|
|
439
|
-
const attachIconSvg = renderLucideIcon(
|
|
440
|
-
attachIconName,
|
|
441
|
-
attachIconSizeNum,
|
|
442
|
-
"currentColor",
|
|
443
|
-
1.5
|
|
444
|
-
);
|
|
445
|
-
if (attachIconSvg) {
|
|
446
|
-
attachmentButton.appendChild(attachIconSvg);
|
|
447
|
-
} else {
|
|
448
|
-
attachmentButton.textContent = "📎";
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Click handler to open file picker
|
|
452
|
-
attachmentButton.addEventListener("click", (e) => {
|
|
453
|
-
e.preventDefault();
|
|
454
|
-
attachmentInput?.click();
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
attachmentButtonWrapper.appendChild(attachmentButton);
|
|
458
|
-
|
|
459
|
-
// Add tooltip if configured
|
|
460
|
-
const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
|
|
461
|
-
const tooltip = createElement("div", "persona-send-button-tooltip");
|
|
462
|
-
tooltip.textContent = attachTooltipText;
|
|
463
|
-
attachmentButtonWrapper.appendChild(tooltip);
|
|
464
|
-
}
|
|
93
|
+
// The bare class names (persona-widget-composer__actions / __left-actions /
|
|
94
|
+
// __right-actions) are stable CSS hooks. The pill composer reuses
|
|
95
|
+
// __left-actions / __right-actions as semantic markers in its grid.
|
|
96
|
+
const actionsRow = createElement(
|
|
97
|
+
"div",
|
|
98
|
+
"persona-widget-composer__actions persona-flex persona-items-center persona-justify-between persona-w-full"
|
|
99
|
+
);
|
|
100
|
+
const leftActions = createElement(
|
|
101
|
+
"div",
|
|
102
|
+
"persona-widget-composer__left-actions persona-flex persona-items-center persona-gap-2"
|
|
103
|
+
);
|
|
104
|
+
const rightActions = createElement(
|
|
105
|
+
"div",
|
|
106
|
+
"persona-widget-composer__right-actions persona-flex persona-items-center persona-gap-1"
|
|
107
|
+
);
|
|
108
|
+
if (attachment) leftActions.append(attachment.wrapper);
|
|
109
|
+
if (mic) rightActions.append(mic.wrapper);
|
|
110
|
+
rightActions.append(send.wrapper);
|
|
111
|
+
actionsRow.append(leftActions, rightActions);
|
|
112
|
+
composerForm.append(actionsRow);
|
|
465
113
|
|
|
466
|
-
//
|
|
114
|
+
// Click anywhere on the composer (other than the action buttons) → focus
|
|
115
|
+
// textarea so the click target feels like the whole input bar.
|
|
467
116
|
composerForm.addEventListener("click", (e) => {
|
|
468
|
-
// Don't focus if clicking on the send button, mic button, attachment button, or their wrappers
|
|
469
117
|
if (
|
|
470
|
-
e.target !==
|
|
471
|
-
e.target !==
|
|
472
|
-
e.target !==
|
|
473
|
-
e.target !==
|
|
474
|
-
e.target !==
|
|
475
|
-
e.target !==
|
|
118
|
+
e.target !== send.button &&
|
|
119
|
+
e.target !== send.wrapper &&
|
|
120
|
+
e.target !== mic?.button &&
|
|
121
|
+
e.target !== mic?.wrapper &&
|
|
122
|
+
e.target !== attachment?.button &&
|
|
123
|
+
e.target !== attachment?.wrapper
|
|
476
124
|
) {
|
|
477
125
|
textarea.focus();
|
|
478
126
|
}
|
|
479
127
|
});
|
|
480
128
|
|
|
481
|
-
// Layout structure:
|
|
482
|
-
// - Row 1: Image previews (smaller, above textarea)
|
|
483
|
-
// - Row 2: Textarea (full width)
|
|
484
|
-
// - Row 3: Actions row (attachment left, mic/send right)
|
|
485
|
-
|
|
486
|
-
// Add image previews first (above textarea)
|
|
487
|
-
if (attachmentPreviewsContainer) {
|
|
488
|
-
// Make previews smaller
|
|
489
|
-
attachmentPreviewsContainer.style.gap = "8px";
|
|
490
|
-
composerForm.append(attachmentPreviewsContainer);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Hidden file input
|
|
494
|
-
if (attachmentInput) {
|
|
495
|
-
composerForm.append(attachmentInput);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Textarea row (full width)
|
|
499
|
-
composerForm.append(textarea);
|
|
500
|
-
|
|
501
|
-
// Actions row: attachment on left, mic/send on right
|
|
502
|
-
const actionsRow = createElement("div", "persona-flex persona-items-center persona-justify-between persona-w-full");
|
|
503
|
-
|
|
504
|
-
// Left side: attachment button
|
|
505
|
-
const leftActions = createElement("div", "persona-flex persona-items-center persona-gap-2");
|
|
506
|
-
if (attachmentButtonWrapper) {
|
|
507
|
-
leftActions.append(attachmentButtonWrapper);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Right side: mic and send buttons
|
|
511
|
-
const rightActions = createElement("div", "persona-flex persona-items-center persona-gap-1");
|
|
512
|
-
if (micButtonWrapper) {
|
|
513
|
-
rightActions.append(micButtonWrapper);
|
|
514
|
-
}
|
|
515
|
-
rightActions.append(sendButtonWrapper);
|
|
516
|
-
|
|
517
|
-
actionsRow.append(leftActions, rightActions);
|
|
518
|
-
composerForm.append(actionsRow);
|
|
519
|
-
|
|
520
|
-
// Apply status indicator config
|
|
521
|
-
const statusConfig = config?.statusIndicator ?? {};
|
|
522
|
-
const alignClass =
|
|
523
|
-
statusConfig.align === "left" ? "persona-text-left"
|
|
524
|
-
: statusConfig.align === "center" ? "persona-text-center"
|
|
525
|
-
: "persona-text-right";
|
|
526
|
-
const statusText = createElement(
|
|
527
|
-
"div",
|
|
528
|
-
`persona-mt-2 ${alignClass} persona-text-xs persona-text-persona-muted`
|
|
529
|
-
);
|
|
530
|
-
statusText.setAttribute("data-persona-composer-status", "");
|
|
531
|
-
|
|
532
|
-
const isVisible = statusConfig.visible ?? true;
|
|
533
|
-
statusText.style.display = isVisible ? "" : "none";
|
|
534
|
-
const idleLabel = statusConfig.idleText ?? "Online";
|
|
535
|
-
if (statusConfig.idleLink) {
|
|
536
|
-
const link = createElement("a");
|
|
537
|
-
link.href = statusConfig.idleLink;
|
|
538
|
-
link.target = "_blank";
|
|
539
|
-
link.rel = "noopener noreferrer";
|
|
540
|
-
link.textContent = idleLabel;
|
|
541
|
-
link.style.color = "inherit";
|
|
542
|
-
link.style.textDecoration = "none";
|
|
543
|
-
statusText.appendChild(link);
|
|
544
|
-
} else {
|
|
545
|
-
statusText.textContent = idleLabel;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
129
|
footer.append(suggestions, composerForm, statusText);
|
|
549
130
|
|
|
550
131
|
return {
|
|
@@ -552,22 +133,18 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
552
133
|
suggestions,
|
|
553
134
|
composerForm,
|
|
554
135
|
textarea,
|
|
555
|
-
sendButton,
|
|
556
|
-
sendButtonWrapper,
|
|
557
|
-
micButton,
|
|
558
|
-
micButtonWrapper,
|
|
136
|
+
sendButton: send.button,
|
|
137
|
+
sendButtonWrapper: send.wrapper,
|
|
138
|
+
micButton: mic?.button ?? null,
|
|
139
|
+
micButtonWrapper: mic?.wrapper ?? null,
|
|
559
140
|
statusText,
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
attachmentPreviewsContainer,
|
|
565
|
-
// Actions row layout elements
|
|
141
|
+
attachmentButton: attachment?.button ?? null,
|
|
142
|
+
attachmentButtonWrapper: attachment?.wrapper ?? null,
|
|
143
|
+
attachmentInput: attachment?.input ?? null,
|
|
144
|
+
attachmentPreviewsContainer: attachment?.previewsContainer ?? null,
|
|
566
145
|
actionsRow,
|
|
567
146
|
leftActions,
|
|
568
147
|
rightActions,
|
|
569
|
-
setSendButtonMode
|
|
148
|
+
setSendButtonMode: send.setMode,
|
|
570
149
|
};
|
|
571
150
|
};
|
|
572
|
-
|
|
573
|
-
|