@cnt-dev/chat-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +202 -0
  2. package/LICENSE +21 -0
  3. package/README.md +303 -0
  4. package/index.d.ts +23 -0
  5. package/package.json +41 -0
  6. package/resources/chatui/.library +14 -0
  7. package/resources/chatui/ChatBox-dbg.js +1099 -0
  8. package/resources/chatui/ChatBox-dbg.js.map +1 -0
  9. package/resources/chatui/ChatBox.d.ts +308 -0
  10. package/resources/chatui/ChatBox.d.ts.map +1 -0
  11. package/resources/chatui/ChatBox.gen.d.ts +545 -0
  12. package/resources/chatui/ChatBox.js +5 -0
  13. package/resources/chatui/ChatBox.js.map +1 -0
  14. package/resources/chatui/ChatBox.ts +1153 -0
  15. package/resources/chatui/library-dbg.js +28 -0
  16. package/resources/chatui/library-dbg.js.map +1 -0
  17. package/resources/chatui/library-preload.js +17 -0
  18. package/resources/chatui/library-preload.js.map +1 -0
  19. package/resources/chatui/library.d.ts +3 -0
  20. package/resources/chatui/library.d.ts.map +1 -0
  21. package/resources/chatui/library.js +5 -0
  22. package/resources/chatui/library.js.map +1 -0
  23. package/resources/chatui/library.ts +30 -0
  24. package/resources/chatui/manifest.json +52 -0
  25. package/resources/chatui/markdown-dbg.js +116 -0
  26. package/resources/chatui/markdown-dbg.js.map +1 -0
  27. package/resources/chatui/markdown.d.ts +21 -0
  28. package/resources/chatui/markdown.d.ts.map +1 -0
  29. package/resources/chatui/markdown.js +5 -0
  30. package/resources/chatui/markdown.js.map +1 -0
  31. package/resources/chatui/markdown.ts +140 -0
  32. package/resources/chatui/messagebundle.properties +20 -0
  33. package/resources/chatui/messagebundle_en.properties +20 -0
  34. package/resources/chatui/themes/base/ChatBox.less +702 -0
  35. package/resources/chatui/themes/base/library-RTL.css +3 -0
  36. package/resources/chatui/themes/base/library-parameters.json +1 -0
  37. package/resources/chatui/themes/base/library.css +3 -0
  38. package/resources/chatui/themes/base/library.source.less +4 -0
  39. package/resources/chatui/themes/sap_fiori_3/library-RTL.css +3 -0
  40. package/resources/chatui/themes/sap_fiori_3/library-parameters.json +1 -0
  41. package/resources/chatui/themes/sap_fiori_3/library.css +3 -0
  42. package/resources/chatui/themes/sap_fiori_3/library.source.less +3 -0
  43. package/resources/chatui/themes/sap_fiori_3_dark/library-RTL.css +3 -0
  44. package/resources/chatui/themes/sap_fiori_3_dark/library-parameters.json +1 -0
  45. package/resources/chatui/themes/sap_fiori_3_dark/library.css +3 -0
  46. package/resources/chatui/themes/sap_fiori_3_dark/library.source.less +3 -0
  47. package/resources/chatui/themes/sap_fiori_3_hcb/library-RTL.css +3 -0
  48. package/resources/chatui/themes/sap_fiori_3_hcb/library-parameters.json +1 -0
  49. package/resources/chatui/themes/sap_fiori_3_hcb/library.css +3 -0
  50. package/resources/chatui/themes/sap_fiori_3_hcb/library.source.less +3 -0
  51. package/resources/chatui/themes/sap_fiori_3_hcw/library-RTL.css +3 -0
  52. package/resources/chatui/themes/sap_fiori_3_hcw/library-parameters.json +1 -0
  53. package/resources/chatui/themes/sap_fiori_3_hcw/library.css +3 -0
  54. package/resources/chatui/themes/sap_fiori_3_hcw/library.source.less +3 -0
  55. package/ui5.yaml +15 -0
@@ -0,0 +1,1153 @@
1
+ /*!
2
+ * ${copyright}
3
+ */
4
+
5
+ import Control from "sap/ui/core/Control";
6
+ import RenderManager from "sap/ui/core/RenderManager";
7
+ import IconPool from "sap/ui/core/IconPool";
8
+ import Lib from "sap/ui/core/Lib";
9
+ import Localization from "sap/base/i18n/Localization";
10
+ import ResourceBundle from "sap/base/i18n/ResourceBundle";
11
+ import markdownToHtml from "./markdown";
12
+
13
+ interface IconInfo {
14
+ fontFamily: string;
15
+ content: string;
16
+ }
17
+
18
+ export interface Message {
19
+ role: "user" | "assistant";
20
+ content: string;
21
+ files?: File[];
22
+ control?: Control;
23
+ }
24
+
25
+ export interface Conversation {
26
+ id: string;
27
+ title?: string;
28
+ messages?: ReadonlyArray<Message>;
29
+ updatedAt?: number | string | Date;
30
+ }
31
+
32
+ /**
33
+ * Constructor for a new <code>chatui.ChatBox</code> control.
34
+ *
35
+ * A flexible chat UI block that can be embedded into any UI5 application.
36
+ * It renders a conversation sidebar, a scrollable message list, an
37
+ * auto-growing input textarea and a send button. The control is
38
+ * intentionally transport-agnostic: it fires a <code>send</code> event with
39
+ * the user's message and expects the embedding application to feed the
40
+ * assistant's reply back via {@link #addAssistantMessage} /
41
+ * {@link #appendToLastAssistantMessage}.
42
+ *
43
+ * The control fills the space provided by its parent. For standalone use
44
+ * a sensible default size is applied via CSS.
45
+ *
46
+ * @extends sap.ui.core.Control
47
+ *
48
+ * @author CONET
49
+ * @version 0.1.0
50
+ *
51
+ * @constructor
52
+ * @public
53
+ * @name chatui.ChatBox
54
+ */
55
+ export default class ChatBox extends Control {
56
+
57
+ // The following three lines were generated and should remain as-is to make TypeScript aware of the constructor signatures
58
+ constructor(id?: string | $ChatBoxSettings);
59
+ constructor(id?: string, settings?: $ChatBoxSettings);
60
+ constructor(id?: string, settings?: $ChatBoxSettings) { super(id, settings); }
61
+
62
+ static readonly metadata = {
63
+ library: "chatui",
64
+ properties: {
65
+ /**
66
+ * Placeholder shown in the empty input field. If empty, a
67
+ * localised default is used.
68
+ */
69
+ placeholder: { type: "string", group: "Appearance", defaultValue: "" },
70
+ /**
71
+ * When true, the input and buttons are disabled — typically set
72
+ * while the assistant is producing a reply.
73
+ */
74
+ busy: { type: "boolean", group: "Behavior", defaultValue: false },
75
+ /**
76
+ * CSS width of the control. Defaults to <code>100%</code> so the
77
+ * chat adapts to its parent; override for a fixed size.
78
+ */
79
+ width: { type: "sap.ui.core.CSSSize", group: "Dimension", defaultValue: "100%" },
80
+ /**
81
+ * CSS height of the control. Defaults to <code>100%</code>; falls
82
+ * back to a sensible min-height when the parent has no explicit
83
+ * height.
84
+ */
85
+ height: { type: "sap.ui.core.CSSSize", group: "Dimension", defaultValue: "100%" },
86
+ /**
87
+ * When true (default), the user can attach files via the paperclip
88
+ * button and drag-and-drop. Uploaded <code>File</code> objects are
89
+ * handed to the consumer via the <code>files</code> parameter of
90
+ * the <code>send</code> event — the library never performs any
91
+ * network transfer itself.
92
+ */
93
+ allowAttachments: { type: "boolean", group: "Behavior", defaultValue: true },
94
+ /**
95
+ * Value passed to the underlying <code>&lt;input type="file"&gt;</code>
96
+ * as its <code>accept</code> attribute (e.g. <code>"image/*"</code>
97
+ * or <code>".pdf,.docx"</code>). Empty string means "any file".
98
+ */
99
+ attachmentAccept: { type: "string", group: "Behavior", defaultValue: "" },
100
+ /**
101
+ * The list of conversations shown in the sidebar. Each entry has
102
+ * an <code>id</code>, an optional <code>title</code>, an optional
103
+ * ordered array of <code>messages</code>, and an optional
104
+ * <code>updatedAt</code> timestamp (number, ISO string or Date).
105
+ * Passed in by the embedding application and never mutated by
106
+ * the library. When set for the first time, the library
107
+ * automatically selects and displays the first conversation. If
108
+ * the property is empty or unset, an empty new-chat view is
109
+ * shown.
110
+ */
111
+ conversations: { type: "object", group: "Data", defaultValue: [] },
112
+ /**
113
+ * The id of the currently displayed conversation. Empty string
114
+ * means "new chat draft" — no history is shown, and the next
115
+ * <code>send</code> event carries an empty <code>conversationId</code>
116
+ * so the consumer can allocate a fresh id. When set to a value
117
+ * that matches an entry in {@link #getConversations}, that
118
+ * conversation's messages are loaded into the transcript.
119
+ */
120
+ activeConversationId: { type: "string", group: "Data", defaultValue: "" }
121
+ },
122
+ events: {
123
+ /**
124
+ * Fired when the user submits a prompt. The embedding
125
+ * application should produce an assistant response and feed it
126
+ * back via {@link #addAssistantMessage} or
127
+ * {@link #appendToLastAssistantMessage}.
128
+ */
129
+ send: {
130
+ parameters: {
131
+ /** The prompt the user submitted (trimmed; empty when only files are sent). */
132
+ message: { type: "string" },
133
+ /** File objects the user attached; empty array if none. Not transferred by the library — the consumer is responsible for upload/transport. */
134
+ files: { type: "object" },
135
+ /** Id of the conversation the message belongs to. Empty string when the user is composing in a new-chat draft — the consumer typically allocates a fresh id and pushes an updated conversation list back. */
136
+ conversationId: { type: "string" }
137
+ }
138
+ },
139
+ /**
140
+ * Fired when the user clicks the "Neue Konversation" button.
141
+ * The transcript view has already been cleared and
142
+ * <code>activeConversationId</code> reset to <code>""</code> by
143
+ * the time the event fires — listeners typically use this to
144
+ * reset any server-side session state.
145
+ */
146
+ newChat: {},
147
+ /**
148
+ * Fired when the user picks a conversation from the sidebar.
149
+ * The library has already switched the transcript view to that
150
+ * conversation and updated <code>activeConversationId</code>.
151
+ */
152
+ conversationSelect: {
153
+ parameters: {
154
+ /** Id of the conversation the user selected. */
155
+ id: { type: "string" }
156
+ }
157
+ },
158
+ /**
159
+ * Fired when the user clicks the × on a conversation in the
160
+ * sidebar. The library does not mutate the conversations
161
+ * property itself — the consumer is expected to drop the entry
162
+ * from its store and push an updated list back via
163
+ * {@link #setConversations}.
164
+ */
165
+ conversationDelete: {
166
+ parameters: {
167
+ /** Id of the conversation the user asked to delete. */
168
+ id: { type: "string" }
169
+ }
170
+ }
171
+ }
172
+ };
173
+
174
+ private _messages: Message[] = [];
175
+ private _pendingAttachments: File[] = [];
176
+ private _conversations: Conversation[] = [];
177
+ private _initialised = false;
178
+ private _onLocaleChange = (): void => { this.invalidate(); };
179
+
180
+ override init(): void {
181
+ Localization.attachChange(this._onLocaleChange);
182
+ }
183
+
184
+ override exit(): void {
185
+ Localization.detachChange(this._onLocaleChange);
186
+ }
187
+
188
+ static renderer = {
189
+ apiVersion: 2,
190
+ render(rm: RenderManager, control: ChatBox): void {
191
+ const sendIcon = IconPool.getIconInfo("sap-icon://paper-plane") as IconInfo | undefined;
192
+ const newChatIcon = IconPool.getIconInfo("sap-icon://add") as IconInfo | undefined;
193
+ const attachIcon = IconPool.getIconInfo("sap-icon://attachment") as IconInfo | undefined;
194
+ const i18n = control._i18n();
195
+ const placeholder = control.getPlaceholder() || i18n.getText("chatbox.placeholder") || "";
196
+ const allowAttachments = control.getAllowAttachments();
197
+
198
+ rm.openStart("div", control);
199
+ rm.class("cnetChatBox");
200
+ rm.style("width", control.getWidth());
201
+ rm.style("height", control.getHeight());
202
+ rm.openEnd();
203
+
204
+ // ---- Sidebar ----
205
+ rm.openStart("aside");
206
+ rm.class("cnetChatBox-sidebar");
207
+ rm.openEnd();
208
+ rm.openStart("div");
209
+ rm.class("cnetChatBox-sidebar-header");
210
+ rm.openEnd();
211
+ rm.openStart("span");
212
+ rm.class("cnetChatBox-sidebar-title");
213
+ rm.openEnd();
214
+ rm.text(i18n.getText("chatbox.conversations") ?? "");
215
+ rm.close("span");
216
+ rm.close("div");
217
+ rm.openStart("div", control.getId() + "-conversations");
218
+ rm.class("cnetChatBox-conversations");
219
+ rm.openEnd();
220
+ rm.close("div");
221
+ rm.close("aside");
222
+
223
+ // ---- Main column ----
224
+ rm.openStart("div");
225
+ rm.class("cnetChatBox-main");
226
+ rm.openEnd();
227
+
228
+ // Header
229
+ rm.openStart("div");
230
+ rm.class("cnetChatBox-header");
231
+ rm.openEnd();
232
+ rm.openStart("div", control.getId() + "-headerTitle");
233
+ rm.class("cnetChatBox-header-title");
234
+ rm.openEnd();
235
+ rm.close("div");
236
+ rm.openStart("button", control.getId() + "-newChat");
237
+ rm.class("cnetChatBox-newChatButton");
238
+ rm.attr("type", "button");
239
+ rm.attr("title", i18n.getText("chatbox.newConversation") ?? "");
240
+ rm.openEnd();
241
+ rm.openStart("span");
242
+ rm.class("cnetChatBox-icon");
243
+ if (newChatIcon) rm.style("font-family", newChatIcon.fontFamily);
244
+ rm.openEnd();
245
+ rm.text(newChatIcon?.content ?? "+");
246
+ rm.close("span");
247
+ rm.openStart("span");
248
+ rm.class("cnetChatBox-newChatButton-label");
249
+ rm.openEnd();
250
+ rm.text(i18n.getText("chatbox.newConversation") ?? "");
251
+ rm.close("span");
252
+ rm.close("button");
253
+ rm.close("div");
254
+
255
+ // Messages
256
+ rm.openStart("div", control.getId() + "-messages");
257
+ rm.class("cnetChatBox-messages");
258
+ rm.openEnd();
259
+ rm.openStart("div");
260
+ rm.class("cnetChatBox-empty");
261
+ rm.openEnd();
262
+ rm.text(i18n.getText("chatbox.emptyHint") ?? "");
263
+ rm.close("div");
264
+ rm.close("div");
265
+
266
+ // Input area
267
+ rm.openStart("div");
268
+ rm.class("cnetChatBox-inputArea");
269
+ rm.openEnd();
270
+
271
+ rm.openStart("div");
272
+ rm.class("cnetChatBox-composer");
273
+ rm.openEnd();
274
+
275
+ rm.openStart("div", control.getId() + "-pending");
276
+ rm.class("cnetChatBox-attachmentsPending");
277
+ rm.openEnd();
278
+ rm.close("div");
279
+
280
+ rm.openStart("textarea", control.getId() + "-input");
281
+ rm.class("cnetChatBox-textarea");
282
+ rm.attr("placeholder", placeholder);
283
+ rm.attr("rows", "1");
284
+ if (control.getBusy()) rm.attr("disabled", "disabled");
285
+ rm.openEnd();
286
+ rm.close("textarea");
287
+
288
+ rm.openStart("div");
289
+ rm.class("cnetChatBox-composerBar");
290
+ rm.openEnd();
291
+
292
+ rm.openStart("div");
293
+ rm.class("cnetChatBox-composerBar-left");
294
+ rm.openEnd();
295
+
296
+ if (allowAttachments) {
297
+ rm.openStart("button", control.getId() + "-attach");
298
+ rm.class("cnetChatBox-toolButton");
299
+ rm.class("cnetChatBox-attach");
300
+ rm.attr("type", "button");
301
+ rm.attr("title", i18n.getText("chatbox.attach") ?? "");
302
+ rm.attr("aria-label", i18n.getText("chatbox.attach") ?? "");
303
+ rm.openEnd();
304
+ rm.openStart("span");
305
+ rm.class("cnetChatBox-icon");
306
+ if (attachIcon) rm.style("font-family", attachIcon.fontFamily);
307
+ rm.openEnd();
308
+ rm.text(attachIcon?.content ?? "📎");
309
+ rm.close("span");
310
+ rm.close("button");
311
+
312
+ rm.voidStart("input", control.getId() + "-fileinput");
313
+ rm.class("cnetChatBox-fileinput");
314
+ rm.attr("type", "file");
315
+ rm.attr("multiple", "multiple");
316
+ if (control.getAttachmentAccept()) rm.attr("accept", control.getAttachmentAccept());
317
+ rm.voidEnd();
318
+ }
319
+
320
+ rm.close("div");
321
+
322
+ rm.openStart("button", control.getId() + "-send");
323
+ rm.class("cnetChatBox-send");
324
+ rm.attr("type", "button");
325
+ rm.attr("title", i18n.getText("chatbox.send") ?? "");
326
+ rm.attr("aria-label", i18n.getText("chatbox.send") ?? "");
327
+ rm.openEnd();
328
+ rm.openStart("span");
329
+ rm.class("cnetChatBox-icon");
330
+ if (sendIcon) rm.style("font-family", sendIcon.fontFamily);
331
+ rm.openEnd();
332
+ rm.text(sendIcon?.content ?? "➤");
333
+ rm.close("span");
334
+ rm.close("button");
335
+
336
+ rm.close("div");
337
+ rm.close("div");
338
+ rm.close("div");
339
+
340
+ rm.close("div"); // main
341
+
342
+ if (allowAttachments) {
343
+ rm.openStart("div", control.getId() + "-drop");
344
+ rm.class("cnetChatBox-dropOverlay");
345
+ rm.openEnd();
346
+ rm.openStart("span");
347
+ rm.class("cnetChatBox-dropLabel");
348
+ rm.openEnd();
349
+ rm.text(i18n.getText("chatbox.dropHere") ?? "");
350
+ rm.close("span");
351
+ rm.close("div");
352
+ }
353
+
354
+ rm.close("div");
355
+ }
356
+ };
357
+
358
+ override onAfterRendering(): void {
359
+ const textarea = this._dom("input") as HTMLTextAreaElement | null;
360
+ const sendBtn = this._dom("send");
361
+ const newChatBtn = this._dom("newChat");
362
+
363
+ textarea?.addEventListener("input", () => {
364
+ textarea.style.height = "auto";
365
+ textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px";
366
+ });
367
+
368
+ textarea?.addEventListener("keydown", (e: KeyboardEvent) => {
369
+ if (e.key === "Enter" && !e.shiftKey) {
370
+ e.preventDefault();
371
+ this._submit();
372
+ }
373
+ });
374
+
375
+ sendBtn?.addEventListener("click", () => this._submit());
376
+ newChatBtn?.addEventListener("click", () => this._startNewChat());
377
+
378
+ if (this.getAllowAttachments()) {
379
+ this._wireAttachments();
380
+ }
381
+
382
+ this._renderSidebar();
383
+ this._renderHeaderTitle();
384
+ this._restoreMessages();
385
+ this._renderPending();
386
+ }
387
+
388
+ /**
389
+ * Append an assistant message to the transcript. Pass an empty string
390
+ * to open a "pending" bubble that can then be filled incrementally via
391
+ * {@link #appendToLastAssistantMessage}.
392
+ *
393
+ * @public
394
+ */
395
+ addAssistantMessage(text: string): this {
396
+ this._messages.push({ role: "assistant", content: text ?? "" });
397
+ this._appendMessageDom(this._messages.length - 1);
398
+ return this;
399
+ }
400
+
401
+ /**
402
+ * Append a chunk to the last assistant message. Useful when streaming
403
+ * tokens from a language model. If the last message is not a plain
404
+ * assistant text message, a new one is started.
405
+ *
406
+ * @public
407
+ */
408
+ appendToLastAssistantMessage(chunk: string): this {
409
+ if (!chunk) return this;
410
+ const last = this._messages[this._messages.length - 1];
411
+ if (!last || last.role !== "assistant" || last.control) {
412
+ return this.addAssistantMessage(chunk);
413
+ }
414
+ last.content += chunk;
415
+ this._updateBubble(this._messages.length - 1);
416
+ return this;
417
+ }
418
+
419
+ /**
420
+ * Attach an arbitrary UI5 control as a stand-alone assistant bubble
421
+ * (e.g. a <code>sap.viz.ui5.controls.VizFrame</code>, an
422
+ * <code>sap.ui.integration.widgets.Card</code>, or any custom
423
+ * control). The library owns the control's lifecycle from here on —
424
+ * it is destroyed together with the ChatBox as well as on
425
+ * {@link #clear}, {@link #setMessages}, or the user-driven new-chat
426
+ * action. Do not add the same control twice.
427
+ *
428
+ * @public
429
+ */
430
+ addAssistantControl(control: Control): this {
431
+ this.addDependent(control);
432
+ this._messages.push({ role: "assistant", content: "", control });
433
+ this._appendMessageDom(this._messages.length - 1);
434
+ return this;
435
+ }
436
+
437
+ /**
438
+ * Remove all messages from the transcript. Does not fire
439
+ * <code>newChat</code> — that event is reserved for the user-driven
440
+ * clear action. Does not touch the conversations list.
441
+ *
442
+ * @public
443
+ */
444
+ clear(): this {
445
+ this._messages = [];
446
+ this._pendingAttachments = [];
447
+ this.destroyDependents();
448
+ this._restoreMessages();
449
+ this._renderPending();
450
+ return this;
451
+ }
452
+
453
+ /**
454
+ * Read-only snapshot of the current transcript. Intended for
455
+ * persistence — do not mutate.
456
+ *
457
+ * @public
458
+ */
459
+ getMessages(): ReadonlyArray<Message> {
460
+ return this._messages.slice();
461
+ }
462
+
463
+ /**
464
+ * Replace the transcript with the given messages. Intended as a
465
+ * restore path — the consumer stores {@link #getMessages} somewhere
466
+ * (localStorage, backend, …) and hands it back after a reload. Does
467
+ * not fire <code>newChat</code> or <code>send</code>. Pending
468
+ * attachments (a possibly ongoing draft) are left untouched. Invalid
469
+ * entries (missing/unknown <code>role</code> or non-string
470
+ * <code>content</code>) are silently skipped.
471
+ *
472
+ * @public
473
+ */
474
+ setMessages(messages: ReadonlyArray<Message>): this {
475
+ this._messages = this._sanitiseMessages(messages);
476
+ this.destroyDependents();
477
+ this._restoreMessages();
478
+ return this;
479
+ }
480
+
481
+ /**
482
+ * Replace the conversations list shown in the sidebar. On the first
483
+ * call with a non-empty list, the first entry is automatically
484
+ * selected and its messages are loaded into the transcript. Later
485
+ * calls only update the sidebar — call {@link #setActiveConversationId}
486
+ * to switch the visible transcript.
487
+ *
488
+ * @public
489
+ */
490
+ setConversations(conversations: ReadonlyArray<Conversation> | null | undefined): this {
491
+ const list = this._sanitiseConversations(conversations);
492
+ this._conversations = list;
493
+ this.setProperty("conversations", list, true);
494
+
495
+ if (!this._initialised && list.length > 0 && !this.getActiveConversationId()) {
496
+ const first = list[0];
497
+ this.setProperty("activeConversationId", first.id, true);
498
+ this._messages = this._sanitiseMessages(first.messages ?? []);
499
+ this.destroyDependents();
500
+ this._restoreMessages();
501
+ this._renderHeaderTitle();
502
+ }
503
+ this._initialised = true;
504
+ this._renderSidebar();
505
+ return this;
506
+ }
507
+
508
+ /**
509
+ * Read-only snapshot of the conversations currently shown in the
510
+ * sidebar.
511
+ *
512
+ * @public
513
+ */
514
+ getConversations(): ReadonlyArray<Conversation> {
515
+ return this._conversations.slice();
516
+ }
517
+
518
+ /**
519
+ * Switch the transcript view to the given conversation. If the id
520
+ * does not match any conversation (including the empty string), the
521
+ * transcript is cleared — that is the "new chat draft" state.
522
+ *
523
+ * @public
524
+ */
525
+ setActiveConversationId(id: string): this {
526
+ const next = typeof id === "string" ? id : "";
527
+ if (next === this.getActiveConversationId()) return this;
528
+ this.setProperty("activeConversationId", next, true);
529
+ const list = this._conversations ?? [];
530
+ const conv = list.find((c) => c.id === next);
531
+ this._messages = conv ? this._sanitiseMessages(conv.messages ?? []) : [];
532
+ this._pendingAttachments = [];
533
+ this.destroyDependents();
534
+ this._renderSidebar();
535
+ this._renderHeaderTitle();
536
+ this._restoreMessages();
537
+ this._renderPending();
538
+ return this;
539
+ }
540
+
541
+ private _i18n(): ResourceBundle {
542
+ return Lib.getResourceBundleFor("chatui") as ResourceBundle;
543
+ }
544
+
545
+ private _dom(suffix: string): HTMLElement | null {
546
+ return document.getElementById(this.getId() + "-" + suffix);
547
+ }
548
+
549
+ private _messagesContainer(): HTMLElement | null {
550
+ return this._dom("messages");
551
+ }
552
+
553
+ private _sanitiseMessages(messages: ReadonlyArray<Message> | null | undefined): Message[] {
554
+ const clean: Message[] = [];
555
+ if (!Array.isArray(messages)) return clean;
556
+ for (const raw of messages) {
557
+ if (!raw || (raw.role !== "user" && raw.role !== "assistant")) continue;
558
+ if (typeof raw.content !== "string") continue;
559
+ if (raw.control) continue;
560
+ const msg: Message = { role: raw.role, content: raw.content };
561
+ if (Array.isArray(raw.files) && raw.files.length > 0) {
562
+ msg.files = raw.files.slice();
563
+ }
564
+ clean.push(msg);
565
+ }
566
+ return clean;
567
+ }
568
+
569
+ private _sanitiseConversations(conversations: ReadonlyArray<Conversation> | null | undefined): Conversation[] {
570
+ const clean: Conversation[] = [];
571
+ if (!Array.isArray(conversations)) return clean;
572
+ for (const raw of conversations) {
573
+ if (!raw || typeof raw.id !== "string" || !raw.id) continue;
574
+ const conv: Conversation = { id: raw.id };
575
+ if (typeof raw.title === "string") conv.title = raw.title;
576
+ if (Array.isArray(raw.messages)) conv.messages = this._sanitiseMessages(raw.messages);
577
+ if (raw.updatedAt !== undefined && raw.updatedAt !== null) conv.updatedAt = raw.updatedAt;
578
+ clean.push(conv);
579
+ }
580
+ return clean;
581
+ }
582
+
583
+ private _submit(): void {
584
+ if (this.getBusy()) return;
585
+ const textarea = this._dom("input") as HTMLTextAreaElement | null;
586
+ const text = textarea?.value.trim() ?? "";
587
+ const files = this._pendingAttachments.slice();
588
+ if (!text && files.length === 0) return;
589
+
590
+ if (textarea) {
591
+ textarea.value = "";
592
+ textarea.style.height = "auto";
593
+ }
594
+
595
+ const message: Message = { role: "user", content: text };
596
+ if (files.length > 0) message.files = files;
597
+ this._messages.push(message);
598
+ this._pendingAttachments = [];
599
+ this._appendMessageDom(this._messages.length - 1);
600
+ this._renderPending();
601
+
602
+ this.fireEvent("send", { message: text, files, conversationId: this.getActiveConversationId() });
603
+ }
604
+
605
+ private _startNewChat(): void {
606
+ if (this.getBusy()) return;
607
+ this._messages = [];
608
+ this._pendingAttachments = [];
609
+ this.destroyDependents();
610
+ this.setProperty("activeConversationId", "", true);
611
+ this._renderSidebar();
612
+ this._renderHeaderTitle();
613
+ this._restoreMessages();
614
+ this._renderPending();
615
+ this.fireEvent("newChat");
616
+ }
617
+
618
+ private _selectConversation(id: string): void {
619
+ if (this.getBusy()) return;
620
+ if (id === this.getActiveConversationId()) return;
621
+ this.setActiveConversationId(id);
622
+ this.fireEvent("conversationSelect", { id });
623
+ }
624
+
625
+ private _requestDeleteConversation(id: string): void {
626
+ if (this.getBusy()) return;
627
+ this.fireEvent("conversationDelete", { id });
628
+ }
629
+
630
+ private _buildMessageDom(index: number): HTMLDivElement {
631
+ const msg = this._messages[index];
632
+ const div = document.createElement("div");
633
+ const isAssistant = msg.role === "assistant";
634
+ div.className = `cnetChatBox-message cnetChatBox-message--${isAssistant ? "assistant" : "user"}`;
635
+
636
+ const bubble = document.createElement("div");
637
+ bubble.className = "cnetChatBox-bubble";
638
+ this._renderBubble(bubble, msg);
639
+ div.appendChild(bubble);
640
+
641
+ if (!msg.control) {
642
+ const actions = this._buildActions(index, msg);
643
+ if (actions) div.appendChild(actions);
644
+ }
645
+ return div;
646
+ }
647
+
648
+ private _buildActions(index: number, msg: Message): HTMLDivElement | null {
649
+ const actions = document.createElement("div");
650
+ actions.className = "cnetChatBox-actions";
651
+ if (msg.role === "assistant") {
652
+ actions.appendChild(this._buildCopyButton(index));
653
+ } else {
654
+ actions.appendChild(this._buildEditButton(index));
655
+ }
656
+ return actions;
657
+ }
658
+
659
+ private _buildIconButton(iconName: string, className: string, label: string, fallback: string): HTMLButtonElement {
660
+ const icon = IconPool.getIconInfo(iconName) as IconInfo | undefined;
661
+ const btn = document.createElement("button");
662
+ btn.type = "button";
663
+ btn.className = `cnetChatBox-action ${className}`;
664
+ btn.title = label;
665
+ btn.setAttribute("aria-label", label);
666
+
667
+ const iconSpan = document.createElement("span");
668
+ iconSpan.className = "cnetChatBox-icon";
669
+ if (icon) iconSpan.style.fontFamily = icon.fontFamily;
670
+ iconSpan.textContent = icon?.content ?? fallback;
671
+ btn.appendChild(iconSpan);
672
+ return btn;
673
+ }
674
+
675
+ private _buildCopyButton(index: number): HTMLButtonElement {
676
+ const label = this._i18n().getText("chatbox.copy") ?? "Copy";
677
+ const btn = this._buildIconButton("sap-icon://copy", "cnetChatBox-copy", label, "⎘");
678
+ btn.addEventListener("click", () => {
679
+ const msg = this._messages[index];
680
+ if (!msg || !msg.content) return;
681
+ void this._copyMessage(msg.content, btn);
682
+ });
683
+ return btn;
684
+ }
685
+
686
+ private _buildEditButton(index: number): HTMLButtonElement {
687
+ const label = this._i18n().getText("chatbox.edit") ?? "Edit";
688
+ const btn = this._buildIconButton("sap-icon://edit", "cnetChatBox-edit", label, "✎");
689
+ btn.addEventListener("click", () => this._enterEditMode(index));
690
+ return btn;
691
+ }
692
+
693
+ private async _copyMessage(text: string, btn: HTMLButtonElement): Promise<void> {
694
+ const ok = await this._writeClipboard(text);
695
+ if (!ok) return;
696
+
697
+ const i18n = this._i18n();
698
+ const original = btn.getAttribute("aria-label") ?? "";
699
+ const done = i18n.getText("chatbox.copied") ?? "Copied";
700
+ btn.classList.add("cnetChatBox-action--done");
701
+ btn.title = done;
702
+ window.setTimeout(() => {
703
+ btn.classList.remove("cnetChatBox-action--done");
704
+ btn.title = original;
705
+ }, 1200);
706
+ }
707
+
708
+ private _enterEditMode(index: number): void {
709
+ if (this.getBusy()) return;
710
+ const wrapper = this._messagesContainer()?.children[index] as HTMLDivElement | undefined;
711
+ const msg = this._messages[index];
712
+ if (!wrapper || !msg || msg.role !== "user") return;
713
+
714
+ const bubble = wrapper.querySelector<HTMLElement>(".cnetChatBox-bubble");
715
+ if (!bubble) return;
716
+
717
+ const actionsRow = wrapper.querySelector<HTMLElement>(".cnetChatBox-actions");
718
+ if (actionsRow) actionsRow.style.display = "none";
719
+
720
+ bubble.textContent = "";
721
+ bubble.classList.add("cnetChatBox-bubble--editing");
722
+ if (msg.files && msg.files.length > 0) {
723
+ bubble.appendChild(this._buildAttachmentList(msg.files, "sent"));
724
+ }
725
+
726
+ const editor = document.createElement("textarea");
727
+ editor.className = "cnetChatBox-editTextarea";
728
+ editor.value = msg.content;
729
+ editor.rows = 2;
730
+ bubble.appendChild(editor);
731
+
732
+ const editBar = document.createElement("div");
733
+ editBar.className = "cnetChatBox-editActions";
734
+ const i18n = this._i18n();
735
+
736
+ const cancelBtn = document.createElement("button");
737
+ cancelBtn.type = "button";
738
+ cancelBtn.className = "cnetChatBox-editCancel";
739
+ cancelBtn.textContent = i18n.getText("chatbox.cancel") ?? "Cancel";
740
+
741
+ const saveBtn = document.createElement("button");
742
+ saveBtn.type = "button";
743
+ saveBtn.className = "cnetChatBox-editSave";
744
+ saveBtn.textContent = i18n.getText("chatbox.save") ?? "Save";
745
+
746
+ cancelBtn.addEventListener("click", () => this._cancelEdit(index));
747
+ saveBtn.addEventListener("click", () => this._commitEdit(index, editor.value));
748
+
749
+ editor.addEventListener("keydown", (e: KeyboardEvent) => {
750
+ if (e.key === "Enter" && !e.shiftKey) {
751
+ e.preventDefault();
752
+ this._commitEdit(index, editor.value);
753
+ } else if (e.key === "Escape") {
754
+ e.preventDefault();
755
+ this._cancelEdit(index);
756
+ }
757
+ });
758
+
759
+ editBar.appendChild(cancelBtn);
760
+ editBar.appendChild(saveBtn);
761
+ bubble.appendChild(editBar);
762
+
763
+ editor.focus();
764
+ editor.setSelectionRange(editor.value.length, editor.value.length);
765
+ }
766
+
767
+ private _cancelEdit(index: number): void {
768
+ const wrapper = this._messagesContainer()?.children[index] as HTMLDivElement | undefined;
769
+ const msg = this._messages[index];
770
+ if (!wrapper || !msg) return;
771
+ const bubble = wrapper.querySelector<HTMLElement>(".cnetChatBox-bubble");
772
+ if (!bubble) return;
773
+ bubble.classList.remove("cnetChatBox-bubble--editing");
774
+ this._renderBubble(bubble, msg);
775
+ const actionsRow = wrapper.querySelector<HTMLElement>(".cnetChatBox-actions");
776
+ if (actionsRow) actionsRow.style.display = "";
777
+ }
778
+
779
+ private _commitEdit(index: number, newText: string): void {
780
+ if (this.getBusy()) return;
781
+ const msg = this._messages[index];
782
+ if (!msg || msg.role !== "user") return;
783
+
784
+ const trimmed = newText.trim();
785
+ const files = msg.files ? msg.files.slice() : [];
786
+ const unchanged = trimmed === msg.content;
787
+ const empty = !trimmed && files.length === 0;
788
+
789
+ if (unchanged || empty) {
790
+ this._cancelEdit(index);
791
+ return;
792
+ }
793
+
794
+ for (let i = index + 1; i < this._messages.length; i++) {
795
+ const later = this._messages[i];
796
+ if (later.control) later.control.destroy();
797
+ }
798
+ this._messages.length = index + 1;
799
+ msg.content = trimmed;
800
+
801
+ this._restoreMessages();
802
+ this.fireEvent("send", { message: trimmed, files, conversationId: this.getActiveConversationId() });
803
+ }
804
+
805
+ private async _writeClipboard(text: string): Promise<boolean> {
806
+ try {
807
+ if (navigator.clipboard?.writeText) {
808
+ await navigator.clipboard.writeText(text);
809
+ return true;
810
+ }
811
+ } catch {
812
+ // fall through to legacy path
813
+ }
814
+ try {
815
+ const ta = document.createElement("textarea");
816
+ ta.value = text;
817
+ ta.setAttribute("readonly", "");
818
+ ta.style.position = "fixed";
819
+ ta.style.opacity = "0";
820
+ document.body.appendChild(ta);
821
+ ta.select();
822
+ const ok = document.execCommand("copy");
823
+ document.body.removeChild(ta);
824
+ return ok;
825
+ } catch {
826
+ return false;
827
+ }
828
+ }
829
+
830
+ private _renderBubble(bubble: HTMLElement, msg: Message): void {
831
+ if (msg.role === "assistant") {
832
+ if (msg.control) {
833
+ bubble.classList.remove("cnetChatBox-bubble--pending");
834
+ bubble.classList.add("cnetChatBox-bubble--rich");
835
+ bubble.innerHTML = "";
836
+ msg.control.placeAt(bubble);
837
+ return;
838
+ }
839
+ bubble.classList.remove("cnetChatBox-bubble--rich");
840
+ if (!msg.content) {
841
+ bubble.classList.add("cnetChatBox-bubble--pending");
842
+ bubble.innerHTML = '<span class="cnetChatBox-dots"><span></span><span></span><span></span></span>';
843
+ return;
844
+ }
845
+ bubble.classList.remove("cnetChatBox-bubble--pending");
846
+ bubble.innerHTML = markdownToHtml(msg.content);
847
+ return;
848
+ }
849
+
850
+ bubble.textContent = "";
851
+ if (msg.files && msg.files.length > 0) {
852
+ bubble.appendChild(this._buildAttachmentList(msg.files, "sent"));
853
+ }
854
+ if (msg.content) {
855
+ const text = document.createElement("div");
856
+ text.className = "cnetChatBox-bubble-text";
857
+ text.textContent = msg.content;
858
+ bubble.appendChild(text);
859
+ }
860
+ }
861
+
862
+ private _buildAttachmentList(files: File[], variant: "sent" | "pending"): HTMLDivElement {
863
+ const wrap = document.createElement("div");
864
+ wrap.className = `cnetChatBox-attachments cnetChatBox-attachments--${variant}`;
865
+ for (let i = 0; i < files.length; i++) {
866
+ wrap.appendChild(this._buildAttachmentItem(files[i], variant === "pending" ? i : -1));
867
+ }
868
+ return wrap;
869
+ }
870
+
871
+ private _buildAttachmentItem(file: File, pendingIndex: number): HTMLDivElement {
872
+ const isImage = file.type.startsWith("image/");
873
+ const item = document.createElement("div");
874
+ item.className = `cnetChatBox-attachment cnetChatBox-attachment--${isImage ? "image" : "file"}`;
875
+
876
+ if (isImage) {
877
+ const img = document.createElement("img");
878
+ img.className = "cnetChatBox-attachment-thumb";
879
+ img.alt = file.name;
880
+ img.src = URL.createObjectURL(file);
881
+ item.appendChild(img);
882
+ } else {
883
+ const iconInfo = IconPool.getIconInfo("sap-icon://document") as IconInfo | undefined;
884
+ const iconSpan = document.createElement("span");
885
+ iconSpan.className = "cnetChatBox-icon cnetChatBox-attachment-icon";
886
+ if (iconInfo) iconSpan.style.fontFamily = iconInfo.fontFamily;
887
+ iconSpan.textContent = iconInfo?.content ?? "📄";
888
+ item.appendChild(iconSpan);
889
+ }
890
+
891
+ const name = document.createElement("span");
892
+ name.className = "cnetChatBox-attachment-name";
893
+ name.textContent = file.name;
894
+ name.title = file.name;
895
+ item.appendChild(name);
896
+
897
+ if (pendingIndex >= 0) {
898
+ const i18n = this._i18n();
899
+ const removeLabel = i18n.getText("chatbox.removeAttachment") ?? "Remove";
900
+ const btn = document.createElement("button");
901
+ btn.type = "button";
902
+ btn.className = "cnetChatBox-attachment-remove";
903
+ btn.title = removeLabel;
904
+ btn.setAttribute("aria-label", removeLabel);
905
+ btn.textContent = "×";
906
+ btn.addEventListener("click", () => this._removePending(pendingIndex));
907
+ item.appendChild(btn);
908
+ }
909
+ return item;
910
+ }
911
+
912
+ private _appendMessageDom(index: number): void {
913
+ const container = this._messagesContainer();
914
+ if (!container) return;
915
+ container.querySelector(".cnetChatBox-empty")?.remove();
916
+ container.appendChild(this._buildMessageDom(index));
917
+ container.scrollTop = container.scrollHeight;
918
+ }
919
+
920
+ private _updateBubble(index: number): void {
921
+ const container = this._messagesContainer();
922
+ const wrapper = container?.children[index] as HTMLElement | undefined;
923
+ const bubble = wrapper?.querySelector(".cnetChatBox-bubble") as HTMLElement | null;
924
+ if (!bubble || !container) return;
925
+
926
+ this._renderBubble(bubble, this._messages[index]);
927
+
928
+ const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
929
+ if (atBottom) container.scrollTop = container.scrollHeight;
930
+ }
931
+
932
+ private _restoreMessages(): void {
933
+ const container = this._messagesContainer();
934
+ if (!container) return;
935
+ container.innerHTML = "";
936
+
937
+ if (this._messages.length === 0) {
938
+ const hint = document.createElement("div");
939
+ hint.className = "cnetChatBox-empty";
940
+ hint.textContent = this._i18n().getText("chatbox.emptyHint") ?? "";
941
+ container.appendChild(hint);
942
+ return;
943
+ }
944
+
945
+ for (let i = 0; i < this._messages.length; i++) {
946
+ container.appendChild(this._buildMessageDom(i));
947
+ }
948
+ container.scrollTop = container.scrollHeight;
949
+ }
950
+
951
+ private _renderSidebar(): void {
952
+ const container = this._dom("conversations");
953
+ if (!container) return;
954
+ container.innerHTML = "";
955
+
956
+ if (this._conversations.length === 0) {
957
+ const empty = document.createElement("div");
958
+ empty.className = "cnetChatBox-conversations-empty";
959
+ empty.textContent = this._i18n().getText("chatbox.noConversations") ?? "";
960
+ container.appendChild(empty);
961
+ return;
962
+ }
963
+
964
+ const active = this.getActiveConversationId();
965
+ for (const conv of this._conversations) {
966
+ container.appendChild(this._buildConversationItem(conv, conv.id === active));
967
+ }
968
+ }
969
+
970
+ private _buildConversationItem(conv: Conversation, isActive: boolean): HTMLDivElement {
971
+ const i18n = this._i18n();
972
+ const item = document.createElement("div");
973
+ item.className = "cnetChatBox-conversation" + (isActive ? " cnetChatBox-conversation--active" : "");
974
+ item.setAttribute("role", "button");
975
+ item.tabIndex = 0;
976
+
977
+ const body = document.createElement("div");
978
+ body.className = "cnetChatBox-conversation-body";
979
+
980
+ const title = document.createElement("div");
981
+ title.className = "cnetChatBox-conversation-title";
982
+ title.textContent = this._conversationTitle(conv);
983
+ body.appendChild(title);
984
+
985
+ const snippet = document.createElement("div");
986
+ snippet.className = "cnetChatBox-conversation-snippet";
987
+ snippet.textContent = this._conversationSnippet(conv);
988
+ body.appendChild(snippet);
989
+
990
+ const time = document.createElement("div");
991
+ time.className = "cnetChatBox-conversation-time";
992
+ time.textContent = this._formatTimestamp(conv.updatedAt);
993
+ body.appendChild(time);
994
+
995
+ item.appendChild(body);
996
+
997
+ const removeLabel = i18n.getText("chatbox.deleteConversation") ?? "Delete conversation";
998
+ const del = document.createElement("button");
999
+ del.type = "button";
1000
+ del.className = "cnetChatBox-conversation-remove";
1001
+ del.title = removeLabel;
1002
+ del.setAttribute("aria-label", removeLabel);
1003
+ del.textContent = "×";
1004
+ del.addEventListener("click", (e: MouseEvent) => {
1005
+ e.stopPropagation();
1006
+ this._requestDeleteConversation(conv.id);
1007
+ });
1008
+ item.appendChild(del);
1009
+
1010
+ item.addEventListener("click", () => this._selectConversation(conv.id));
1011
+ item.addEventListener("keydown", (e: KeyboardEvent) => {
1012
+ if (e.key === "Enter" || e.key === " ") {
1013
+ e.preventDefault();
1014
+ this._selectConversation(conv.id);
1015
+ }
1016
+ });
1017
+
1018
+ return item;
1019
+ }
1020
+
1021
+ private _conversationTitle(conv: Conversation): string {
1022
+ if (conv.title && conv.title.trim()) return conv.title.trim();
1023
+ const msgs = conv.messages ?? [];
1024
+ for (const m of msgs) {
1025
+ if (m.role === "user" && m.content && m.content.trim()) {
1026
+ return this._truncate(m.content.trim(), 40);
1027
+ }
1028
+ }
1029
+ return this._i18n().getText("chatbox.untitledConversation") ?? "Neue Konversation";
1030
+ }
1031
+
1032
+ private _conversationSnippet(conv: Conversation): string {
1033
+ const msgs = conv.messages ?? [];
1034
+ for (let i = msgs.length - 1; i >= 0; i--) {
1035
+ const m = msgs[i];
1036
+ if (m.content && m.content.trim()) {
1037
+ const prefix = m.role === "user" ? (this._i18n().getText("chatbox.youPrefix") ?? "Du:") + " " : "";
1038
+ return this._truncate(prefix + m.content.trim().replace(/\s+/g, " "), 60);
1039
+ }
1040
+ }
1041
+ return "";
1042
+ }
1043
+
1044
+ private _truncate(text: string, max: number): string {
1045
+ return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
1046
+ }
1047
+
1048
+ private _formatTimestamp(raw: number | string | Date | undefined): string {
1049
+ if (raw === undefined || raw === null) return "";
1050
+ const d = raw instanceof Date ? raw : new Date(raw);
1051
+ if (isNaN(d.getTime())) return "";
1052
+ const now = new Date();
1053
+ const sameDay = d.toDateString() === now.toDateString();
1054
+ const yesterday = new Date(now);
1055
+ yesterday.setDate(now.getDate() - 1);
1056
+ const wasYesterday = d.toDateString() === yesterday.toDateString();
1057
+ const locale = Localization.getLanguage() || undefined;
1058
+ if (sameDay) {
1059
+ return d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
1060
+ }
1061
+ if (wasYesterday) {
1062
+ return this._i18n().getText("chatbox.yesterday") ?? "Gestern";
1063
+ }
1064
+ const weekDelta = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
1065
+ if (weekDelta < 7) {
1066
+ return d.toLocaleDateString(locale, { weekday: "short" });
1067
+ }
1068
+ return d.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "2-digit" });
1069
+ }
1070
+
1071
+ private _renderHeaderTitle(): void {
1072
+ const target = this._dom("headerTitle");
1073
+ if (!target) return;
1074
+ const active = this.getActiveConversationId();
1075
+ const conv = active ? this._conversations.find((c) => c.id === active) : undefined;
1076
+ target.textContent = conv ? this._conversationTitle(conv) : "";
1077
+ }
1078
+
1079
+ private _wireAttachments(): void {
1080
+ const attachBtn = this._dom("attach");
1081
+ const fileInput = this._dom("fileinput") as HTMLInputElement | null;
1082
+ const root = this.getDomRef() as HTMLElement | null;
1083
+
1084
+ attachBtn?.addEventListener("click", () => fileInput?.click());
1085
+ fileInput?.addEventListener("change", () => {
1086
+ if (!fileInput.files) return;
1087
+ this._addPending(Array.from(fileInput.files));
1088
+ fileInput.value = "";
1089
+ });
1090
+
1091
+ if (!root) return;
1092
+
1093
+ let dragDepth = 0;
1094
+ root.addEventListener("dragenter", (e: DragEvent) => {
1095
+ if (!this._hasFiles(e)) return;
1096
+ e.preventDefault();
1097
+ dragDepth++;
1098
+ root.classList.add("cnetChatBox--dragOver");
1099
+ });
1100
+ root.addEventListener("dragover", (e: DragEvent) => {
1101
+ if (!this._hasFiles(e)) return;
1102
+ e.preventDefault();
1103
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1104
+ });
1105
+ root.addEventListener("dragleave", () => {
1106
+ dragDepth = Math.max(0, dragDepth - 1);
1107
+ if (dragDepth === 0) root.classList.remove("cnetChatBox--dragOver");
1108
+ });
1109
+ root.addEventListener("drop", (e: DragEvent) => {
1110
+ if (!this._hasFiles(e)) return;
1111
+ e.preventDefault();
1112
+ dragDepth = 0;
1113
+ root.classList.remove("cnetChatBox--dragOver");
1114
+ const files = e.dataTransfer?.files;
1115
+ if (!files || files.length === 0) return;
1116
+ this._addPending(Array.from(files));
1117
+ });
1118
+ }
1119
+
1120
+ private _hasFiles(e: DragEvent): boolean {
1121
+ const types = e.dataTransfer?.types;
1122
+ if (!types) return false;
1123
+ for (let i = 0; i < types.length; i++) {
1124
+ if (types[i] === "Files") return true;
1125
+ }
1126
+ return false;
1127
+ }
1128
+
1129
+ private _addPending(files: File[]): void {
1130
+ if (this.getBusy()) return;
1131
+ if (files.length === 0) return;
1132
+ this._pendingAttachments = this._pendingAttachments.concat(files);
1133
+ this._renderPending();
1134
+ }
1135
+
1136
+ private _removePending(index: number): void {
1137
+ if (index < 0 || index >= this._pendingAttachments.length) return;
1138
+ this._pendingAttachments.splice(index, 1);
1139
+ this._renderPending();
1140
+ }
1141
+
1142
+ private _renderPending(): void {
1143
+ const container = this._dom("pending");
1144
+ if (!container) return;
1145
+ container.innerHTML = "";
1146
+ if (this._pendingAttachments.length === 0) {
1147
+ container.classList.remove("cnetChatBox-attachmentsPending--hasItems");
1148
+ return;
1149
+ }
1150
+ container.classList.add("cnetChatBox-attachmentsPending--hasItems");
1151
+ container.appendChild(this._buildAttachmentList(this._pendingAttachments, "pending"));
1152
+ }
1153
+ }