context_drop 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. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,1120 @@
1
+ import type {
2
+ ElementCandidateInfo,
3
+ ElementReferenceSnapshot,
4
+ FeedbackSubmissionPayload,
5
+ PendingAttachmentItem,
6
+ SessionFeedbackEntry,
7
+ WidgetConfig,
8
+ WidgetPosition
9
+ } from "./types.ts";
10
+ import { computeSelector, MANDATORY_EXCLUDED_SELECTORS } from "./sanitizer.ts";
11
+ import {
12
+ ICON_CAMERA,
13
+ ICON_CHAT,
14
+ ICON_CHEVRON_DOWN,
15
+ ICON_CHEVRON_RIGHT,
16
+ ICON_CHEVRON_UP,
17
+ ICON_CLOSE,
18
+ ICON_EXPAND,
19
+ ICON_GRIP,
20
+ ICON_IMAGE,
21
+ ICON_MINIMIZE,
22
+ ICON_PIN,
23
+ ICON_SEND
24
+ } from "./icons.ts";
25
+ import { WIDGET_SHADOW_STYLES } from "./styles.ts";
26
+ import { ContextDropApiClient } from "./api-client.ts";
27
+ import { ElementPicker } from "./element-picker.ts";
28
+ import { captureElementScreenshot, captureViewportScreenshot } from "./screenshot.ts";
29
+
30
+ const DEFAULT_CONFIG: WidgetConfig = {
31
+ enabled: true,
32
+ position: "top_left",
33
+ placeholder: "Describe the issue or drop context from the page...",
34
+ features: {
35
+ element_selection: true,
36
+ screenshot_capture: true,
37
+ element_capture_padding: 16
38
+ },
39
+ limits: {
40
+ max_attachments: 10,
41
+ max_element_references: 10,
42
+ max_attachment_size: 10 * 1024 * 1024,
43
+ allowed_content_types: ["image/png", "image/jpeg", "image/webp", "image/gif"],
44
+ max_image_dimensions: [4096, 4096]
45
+ },
46
+ privacy: {
47
+ excluded_selectors: Array.from(MANDATORY_EXCLUDED_SELECTORS)
48
+ }
49
+ };
50
+
51
+ const BaseHTMLElement: typeof HTMLElement =
52
+ typeof HTMLElement !== "undefined"
53
+ ? HTMLElement
54
+ : (class {} as unknown as typeof HTMLElement);
55
+
56
+ export class ContextDropWidgetElement extends BaseHTMLElement {
57
+ public shadow: ShadowRoot | null = null;
58
+ private config: WidgetConfig = structuredClone(DEFAULT_CONFIG);
59
+ private customMetadata: Record<string, unknown> = {};
60
+ private apiClient: ContextDropApiClient = new ContextDropApiClient("/context_drop");
61
+ private picker: ElementPicker | null = null;
62
+
63
+ private isOpen: boolean = false;
64
+ private isMinimized: boolean = false;
65
+ private isSubmitting: boolean = false;
66
+ private freePos: { left: number; top: number } | null = null;
67
+
68
+ private elementReferences: ElementReferenceSnapshot[] = [];
69
+ private elementDomNodes: Map<string, Element> = new Map();
70
+ private attachments: PendingAttachmentItem[] = [];
71
+ private sessionHistory: SessionFeedbackEntry[] = [];
72
+
73
+ private rootContainer: HTMLDivElement | null = null;
74
+ private composerEl: HTMLDivElement | null = null;
75
+ private compactBarEl: HTMLDivElement | null = null;
76
+ private compactBreadcrumbEl: HTMLDivElement | null = null;
77
+ private compactPinBtn: HTMLButtonElement | null = null;
78
+ private pinDockEl: HTMLDivElement | null = null;
79
+ private floatingPinBtn: HTMLButtonElement | null = null;
80
+ private triggerBtn: HTMLButtonElement | null = null;
81
+ private cornerPinBtn: HTMLButtonElement | null = null;
82
+ private chatThreadEl: HTMLDivElement | null = null;
83
+ private textareaEl: HTMLTextAreaElement | null = null;
84
+ private trayEl: HTMLDivElement | null = null;
85
+ private statusEl: HTMLDivElement | null = null;
86
+ private fileInputEl: HTMLInputElement | null = null;
87
+ private submitBtn: HTMLButtonElement | null = null;
88
+
89
+ public connectedCallback(): void {
90
+ if (!this.shadow) {
91
+ this.shadow = this.attachShadow({ mode: "open" });
92
+ }
93
+ this.readDataAttributes();
94
+ this.renderShadowDom();
95
+
96
+ this.picker = new ElementPicker({
97
+ onSelect: (snapshot, el) => {
98
+ const added = this.addElementReference(snapshot, el);
99
+ if (added) {
100
+ this.showStatus(`Pinned ${snapshot.selector}`, "success");
101
+ }
102
+ this.syncUiStates();
103
+ },
104
+ onExit: () => {
105
+ this.isMinimized = false;
106
+ this.openComposer();
107
+ this.syncUiStates();
108
+ },
109
+ canSelectMore: () => this.elementReferences.length < this.config.limits.max_element_references,
110
+ getExcludedSelectors: () => this.config.privacy.excluded_selectors,
111
+ onStateChange: (state) => {
112
+ this.renderCompactBreadcrumbs(state.activeCandidate, state.ancestors);
113
+ this.syncPinActiveIndicators(state.active);
114
+ }
115
+ });
116
+ }
117
+
118
+ public disconnectedCallback(): void {
119
+ if (this.picker && this.picker.isActive()) {
120
+ this.picker.stop();
121
+ }
122
+ this.picker?.hideExternalPreview();
123
+ }
124
+
125
+ public getConfig(): WidgetConfig {
126
+ return this.config;
127
+ }
128
+
129
+ public getElementReferences(): ElementReferenceSnapshot[] {
130
+ return [...this.elementReferences];
131
+ }
132
+
133
+ public getAttachments(): PendingAttachmentItem[] {
134
+ return [...this.attachments];
135
+ }
136
+
137
+ public getSessionHistory(): SessionFeedbackEntry[] {
138
+ return [...this.sessionHistory];
139
+ }
140
+
141
+ public openComposer(): void {
142
+ this.isOpen = true;
143
+ this.isMinimized = false;
144
+ this.syncUiStates();
145
+ if (this.textareaEl) {
146
+ this.textareaEl.focus();
147
+ }
148
+ }
149
+
150
+ public closeComposer(): void {
151
+ this.isOpen = false;
152
+ this.isMinimized = false;
153
+ if (this.picker && this.picker.isActive()) {
154
+ this.picker.stop();
155
+ }
156
+ this.picker?.hideExternalPreview();
157
+ this.syncUiStates();
158
+ }
159
+
160
+ public toggleComposer(): void {
161
+ if (this.isOpen && !this.isMinimized) {
162
+ this.closeComposer();
163
+ } else {
164
+ this.openComposer();
165
+ }
166
+ }
167
+
168
+ public minimizeToCompactBar(): void {
169
+ this.isOpen = true;
170
+ this.isMinimized = true;
171
+ this.syncUiStates();
172
+ }
173
+
174
+ public expandFromCompactBar(): void {
175
+ if (this.picker && this.picker.isActive()) {
176
+ this.picker.stop();
177
+ }
178
+ this.isMinimized = false;
179
+ this.openComposer();
180
+ }
181
+
182
+ public startPinSelection(): void {
183
+ if (!this.config.features.element_selection || !this.picker) return;
184
+ if (this.elementReferences.length >= this.config.limits.max_element_references) {
185
+ this.openComposer();
186
+ this.showStatus(
187
+ `Maximum of ${this.config.limits.max_element_references} pinned elements reached.`,
188
+ "error"
189
+ );
190
+ return;
191
+ }
192
+ this.minimizeToCompactBar();
193
+ this.picker.start();
194
+ this.syncUiStates();
195
+ }
196
+
197
+ public togglePinSelection(): void {
198
+ if (this.picker && this.picker.isActive()) {
199
+ this.picker.stop();
200
+ } else {
201
+ this.startPinSelection();
202
+ }
203
+ }
204
+
205
+ public setFreePosition(left: number, top: number): void {
206
+ if (!this.rootContainer) return;
207
+ const vw = typeof window !== "undefined" ? window.innerWidth || 1024 : 1024;
208
+ const vh = typeof window !== "undefined" ? window.innerHeight || 768 : 768;
209
+ const clampedLeft = Math.max(8, Math.min(left, vw - 72));
210
+ const clampedTop = Math.max(8, Math.min(top, vh - 48));
211
+
212
+ this.freePos = { left: clampedLeft, top: clampedTop };
213
+ this.rootContainer.classList.add("is-free-positioned");
214
+ this.rootContainer.style.left = `${clampedLeft}px`;
215
+ this.rootContainer.style.top = `${clampedTop}px`;
216
+ }
217
+
218
+ public addElementReference(snapshot: ElementReferenceSnapshot, elementNode?: Element): boolean {
219
+ if (this.elementReferences.length >= this.config.limits.max_element_references) {
220
+ this.showStatus(
221
+ `Maximum of ${this.config.limits.max_element_references} DOM elements reached.`,
222
+ "error"
223
+ );
224
+ return false;
225
+ }
226
+ this.elementReferences.push(snapshot);
227
+ if (elementNode) {
228
+ this.elementDomNodes.set(snapshot.client_ref, elementNode);
229
+ }
230
+ this.renderTray();
231
+ this.syncUiStates();
232
+ return true;
233
+ }
234
+
235
+ public removeElementReference(clientRef: string): void {
236
+ this.elementReferences = this.elementReferences.filter((el) => el.client_ref !== clientRef);
237
+ this.elementDomNodes.delete(clientRef);
238
+ this.picker?.hideExternalPreview();
239
+ this.renderTray();
240
+ this.syncUiStates();
241
+ }
242
+
243
+ public removeAttachment(attachmentId: string): void {
244
+ this.attachments = this.attachments.filter((att) => att.id !== attachmentId);
245
+ this.renderTray();
246
+ this.syncUiStates();
247
+ }
248
+
249
+ private readDataAttributes(): void {
250
+ const endpoint = this.getAttribute("data-endpoint") || "/context_drop";
251
+ this.apiClient = new ContextDropApiClient(endpoint);
252
+
253
+ const rawConfig = this.getAttribute("data-config");
254
+ if (rawConfig) {
255
+ try {
256
+ const parsed = JSON.parse(rawConfig) as Partial<WidgetConfig>;
257
+ this.config = {
258
+ ...this.config,
259
+ ...parsed,
260
+ features: { ...this.config.features, ...(parsed.features || {}) },
261
+ limits: { ...this.config.limits, ...(parsed.limits || {}) },
262
+ privacy: { ...this.config.privacy, ...(parsed.privacy || {}) }
263
+ };
264
+ } catch {
265
+ // Ignore malformed JSON attribute
266
+ }
267
+ }
268
+
269
+ const posOverride = this.getAttribute("data-position") as WidgetPosition | null;
270
+ if (
271
+ posOverride &&
272
+ ["bottom_right", "bottom_left", "top_right", "top_left"].includes(posOverride)
273
+ ) {
274
+ this.config.position = posOverride;
275
+ }
276
+
277
+ const placeholderOverride = this.getAttribute("data-placeholder");
278
+ if (placeholderOverride) {
279
+ this.config.placeholder = placeholderOverride;
280
+ }
281
+
282
+ const rawMetadata = this.getAttribute("data-metadata");
283
+ if (rawMetadata) {
284
+ try {
285
+ this.customMetadata = JSON.parse(rawMetadata) as Record<string, unknown>;
286
+ } catch {
287
+ this.customMetadata = {};
288
+ }
289
+ }
290
+ }
291
+
292
+ private attachDragHandle(handleEl: HTMLElement, onClickFallback?: () => void): void {
293
+ let startX = 0;
294
+ let startY = 0;
295
+ let initialLeft = 0;
296
+ let initialTop = 0;
297
+ let dragging = false;
298
+ let moved = false;
299
+
300
+ const onPointerMove = (ev: PointerEvent): void => {
301
+ if (!dragging) return;
302
+ const dx = ev.clientX - startX;
303
+ const dy = ev.clientY - startY;
304
+ if (!moved && Math.hypot(dx, dy) > 5) {
305
+ moved = true;
306
+ this.pinDockEl?.classList.add("is-dragging");
307
+ }
308
+ if (moved) {
309
+ this.setFreePosition(initialLeft + dx, initialTop + dy);
310
+ }
311
+ };
312
+
313
+ const onPointerUp = (): void => {
314
+ if (!dragging) return;
315
+ dragging = false;
316
+ this.pinDockEl?.classList.remove("is-dragging");
317
+ window.removeEventListener("pointermove", onPointerMove);
318
+ window.removeEventListener("pointerup", onPointerUp);
319
+ if (!moved && onClickFallback) {
320
+ onClickFallback();
321
+ }
322
+ };
323
+
324
+ handleEl.addEventListener("pointerdown", (ev: PointerEvent) => {
325
+ // Only primary mouse button or touch
326
+ if (ev.button !== 0) return;
327
+ const targetEl = ev.target instanceof Element ? ev.target : null;
328
+ if (
329
+ targetEl &&
330
+ handleEl !== this.pinDockEl &&
331
+ targetEl.closest("button")
332
+ ) {
333
+ return;
334
+ }
335
+
336
+ if (!this.rootContainer) return;
337
+ const rect = this.rootContainer.getBoundingClientRect();
338
+ startX = ev.clientX;
339
+ startY = ev.clientY;
340
+ initialLeft = this.freePos ? this.freePos.left : rect.left;
341
+ initialTop = this.freePos ? this.freePos.top : rect.top;
342
+ dragging = true;
343
+ moved = false;
344
+
345
+ window.addEventListener("pointermove", onPointerMove);
346
+ window.addEventListener("pointerup", onPointerUp);
347
+ });
348
+ }
349
+
350
+ private renderShadowDom(): void {
351
+ if (!this.shadow) return;
352
+ this.shadow.innerHTML = "";
353
+
354
+ const styleEl = document.createElement("style");
355
+ styleEl.textContent = WIDGET_SHADOW_STYLES;
356
+ this.shadow.appendChild(styleEl);
357
+
358
+ const root = document.createElement("div");
359
+ root.className = `cd-root pos-${this.config.position}`;
360
+ this.rootContainer = root;
361
+
362
+ // 1. Compact Minimized Pinning Bar (visible while pinning elements on the page or minimized)
363
+ const compactBar = document.createElement("div");
364
+ compactBar.className = "cd-compact-bar";
365
+ compactBar.setAttribute("role", "region");
366
+ compactBar.setAttribute("aria-label", "ContextDrop Pinning Bar");
367
+ compactBar.hidden = true;
368
+ this.compactBarEl = compactBar;
369
+
370
+ const compactGrip = document.createElement("span");
371
+ compactGrip.className = "cd-pin-dock-grip";
372
+ compactGrip.title = "Drag to move pinning bar";
373
+ compactGrip.innerHTML = ICON_GRIP;
374
+ this.attachDragHandle(compactBar);
375
+ compactBar.appendChild(compactGrip);
376
+
377
+ const compactPinBtn = document.createElement("button");
378
+ compactPinBtn.type = "button";
379
+ compactPinBtn.className = "cd-pin-corner-btn is-active";
380
+ compactPinBtn.setAttribute("aria-label", "Toggle element pin mode");
381
+ compactPinBtn.innerHTML = `${ICON_PIN}<span>Pinning</span>`;
382
+ compactPinBtn.addEventListener("click", () => this.togglePinSelection());
383
+ this.compactPinBtn = compactPinBtn;
384
+ compactBar.appendChild(compactPinBtn);
385
+
386
+ const compactStatus = document.createElement("div");
387
+ compactStatus.className = "cd-compact-status";
388
+ const breadcrumbScroll = document.createElement("div");
389
+ breadcrumbScroll.className = "cd-breadcrumb-scroll";
390
+ this.compactBreadcrumbEl = breadcrumbScroll;
391
+ compactStatus.appendChild(breadcrumbScroll);
392
+ compactBar.appendChild(compactStatus);
393
+
394
+ const compactLayerUp = document.createElement("button");
395
+ compactLayerUp.type = "button";
396
+ compactLayerUp.className = "cd-icon-btn";
397
+ compactLayerUp.title = "Step to outer/parent element (Up arrow)";
398
+ compactLayerUp.setAttribute("aria-label", "Step to parent element");
399
+ compactLayerUp.innerHTML = ICON_CHEVRON_UP;
400
+ compactLayerUp.addEventListener("click", () => this.picker?.cycleDepth(1));
401
+ compactBar.appendChild(compactLayerUp);
402
+
403
+ const compactLayerDown = document.createElement("button");
404
+ compactLayerDown.type = "button";
405
+ compactLayerDown.className = "cd-icon-btn";
406
+ compactLayerDown.title = "Step to inner/child element (Down arrow)";
407
+ compactLayerDown.setAttribute("aria-label", "Step to child element");
408
+ compactLayerDown.innerHTML = ICON_CHEVRON_DOWN;
409
+ compactLayerDown.addEventListener("click", () => this.picker?.cycleDepth(-1));
410
+ compactBar.appendChild(compactLayerDown);
411
+
412
+ const expandBtn = document.createElement("button");
413
+ expandBtn.type = "button";
414
+ expandBtn.className = "cd-btn cd-btn-primary";
415
+ expandBtn.setAttribute("aria-label", "Return to chatbox");
416
+ expandBtn.innerHTML = `${ICON_EXPAND}<span>Chat</span>`;
417
+ expandBtn.addEventListener("click", () => this.expandFromCompactBar());
418
+ compactBar.appendChild(expandBtn);
419
+
420
+ // 2. Main Floating Chatbox Composer Window
421
+ const composer = document.createElement("div");
422
+ composer.className = "cd-composer";
423
+ composer.setAttribute("role", "dialog");
424
+ composer.setAttribute("aria-label", "ContextDrop Feedback Chatbox");
425
+ composer.hidden = !this.isOpen;
426
+ this.composerEl = composer;
427
+
428
+ // Drag-and-drop image support on the chatbox
429
+ composer.addEventListener("dragover", (ev) => {
430
+ ev.preventDefault();
431
+ composer.classList.add("is-dragging-over");
432
+ });
433
+ composer.addEventListener("dragleave", () => {
434
+ composer.classList.remove("is-dragging-over");
435
+ });
436
+ composer.addEventListener("drop", (ev) => {
437
+ ev.preventDefault();
438
+ composer.classList.remove("is-dragging-over");
439
+ const files = ev.dataTransfer?.files;
440
+ if (files && files.length > 0) {
441
+ void this.handleFilesSelected(Array.from(files));
442
+ }
443
+ });
444
+
445
+ // Header with Top-Left Pin-Shaped Selector & Drag Handle
446
+ const header = document.createElement("div");
447
+ header.className = "cd-composer-header";
448
+ this.attachDragHandle(header);
449
+
450
+ const headerLeft = document.createElement("div");
451
+ headerLeft.className = "cd-header-left";
452
+
453
+ if (this.config.features.element_selection) {
454
+ const cornerPinBtn = document.createElement("button");
455
+ cornerPinBtn.type = "button";
456
+ cornerPinBtn.className = "cd-pin-corner-btn";
457
+ cornerPinBtn.title = "Pin any HTML element on the page as context";
458
+ cornerPinBtn.setAttribute("aria-label", "Select DOM element on page");
459
+ cornerPinBtn.innerHTML = `${ICON_PIN}<span>Pin Element</span>`;
460
+ cornerPinBtn.addEventListener("click", () => this.startPinSelection());
461
+ this.cornerPinBtn = cornerPinBtn;
462
+ headerLeft.appendChild(cornerPinBtn);
463
+ }
464
+
465
+ const title = document.createElement("h2");
466
+ title.className = "cd-composer-title";
467
+ title.textContent = "ContextDrop";
468
+ headerLeft.appendChild(title);
469
+
470
+ const headerActions = document.createElement("div");
471
+ headerActions.className = "cd-header-actions";
472
+
473
+ const minBtn = document.createElement("button");
474
+ minBtn.type = "button";
475
+ minBtn.className = "cd-icon-btn";
476
+ minBtn.title = "Minimize to compact bar";
477
+ minBtn.setAttribute("aria-label", "Minimize chatbox to compact bar");
478
+ minBtn.innerHTML = ICON_MINIMIZE;
479
+ minBtn.addEventListener("click", () => this.minimizeToCompactBar());
480
+
481
+ const closeBtn = document.createElement("button");
482
+ closeBtn.type = "button";
483
+ closeBtn.className = "cd-icon-btn";
484
+ closeBtn.title = "Close chatbox (Esc)";
485
+ closeBtn.setAttribute("aria-label", "Close feedback composer");
486
+ closeBtn.innerHTML = ICON_CLOSE;
487
+ closeBtn.addEventListener("click", () => this.closeComposer());
488
+
489
+ headerActions.appendChild(minBtn);
490
+ headerActions.appendChild(closeBtn);
491
+ header.appendChild(headerLeft);
492
+ header.appendChild(headerActions);
493
+ composer.appendChild(header);
494
+
495
+ // Conversational Chat Thread
496
+ const chatThread = document.createElement("div");
497
+ chatThread.className = "cd-chat-thread";
498
+ chatThread.setAttribute("aria-live", "polite");
499
+ this.chatThreadEl = chatThread;
500
+ composer.appendChild(chatThread);
501
+ this.renderChatThread();
502
+
503
+ // Pinned Elements & Attachments Chip Tray
504
+ const tray = document.createElement("div");
505
+ tray.className = "cd-tray";
506
+ tray.setAttribute("aria-label", "Attached context items");
507
+ this.trayEl = tray;
508
+ composer.appendChild(tray);
509
+
510
+ // Status message bar
511
+ const status = document.createElement("div");
512
+ status.className = "cd-status";
513
+ status.setAttribute("role", "status");
514
+ status.hidden = true;
515
+ this.statusEl = status;
516
+ composer.appendChild(status);
517
+
518
+ // Hidden file input
519
+ const fileInput = document.createElement("input");
520
+ fileInput.type = "file";
521
+ fileInput.multiple = true;
522
+ fileInput.accept = this.config.limits.allowed_content_types.join(",");
523
+ fileInput.hidden = true;
524
+ fileInput.addEventListener("change", () => {
525
+ if (fileInput.files) {
526
+ void this.handleFilesSelected(Array.from(fileInput.files));
527
+ fileInput.value = "";
528
+ }
529
+ });
530
+ this.fileInputEl = fileInput;
531
+ composer.appendChild(fileInput);
532
+
533
+ // Chatbox Prompt Input Shell
534
+ const promptShell = document.createElement("div");
535
+ promptShell.className = "cd-prompt-shell";
536
+
537
+ const textarea = document.createElement("textarea");
538
+ textarea.className = "cd-textarea";
539
+ textarea.placeholder = this.config.placeholder;
540
+ textarea.setAttribute("aria-label", "Feedback description");
541
+ textarea.addEventListener("keydown", (ev) => {
542
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
543
+ ev.preventDefault();
544
+ void this.handleSubmit();
545
+ } else if (ev.key === "Escape") {
546
+ ev.preventDefault();
547
+ this.closeComposer();
548
+ }
549
+ });
550
+ textarea.addEventListener("paste", (ev) => this.handlePaste(ev));
551
+ this.textareaEl = textarea;
552
+ promptShell.appendChild(textarea);
553
+
554
+ // Toolbar inside prompt shell
555
+ const toolbar = document.createElement("div");
556
+ toolbar.className = "cd-toolbar";
557
+
558
+ const actions = document.createElement("div");
559
+ actions.className = "cd-toolbar-actions";
560
+
561
+ if (this.config.features.element_selection) {
562
+ const pickBtn = document.createElement("button");
563
+ pickBtn.type = "button";
564
+ pickBtn.className = "cd-btn";
565
+ pickBtn.setAttribute("aria-label", "Pin HTML element as context");
566
+ pickBtn.innerHTML = `${ICON_PIN}<span>Pin</span>`;
567
+ pickBtn.addEventListener("click", () => this.startPinSelection());
568
+ actions.appendChild(pickBtn);
569
+ }
570
+
571
+ if (this.config.features.screenshot_capture) {
572
+ const screenshotBtn = document.createElement("button");
573
+ screenshotBtn.type = "button";
574
+ screenshotBtn.className = "cd-btn";
575
+ screenshotBtn.setAttribute("aria-label", "Capture viewport screenshot");
576
+ screenshotBtn.innerHTML = `${ICON_CAMERA}<span>Screenshot</span>`;
577
+ screenshotBtn.addEventListener("click", () => {
578
+ void this.handleViewportScreenshot();
579
+ });
580
+ actions.appendChild(screenshotBtn);
581
+ }
582
+
583
+ const uploadBtn = document.createElement("button");
584
+ uploadBtn.type = "button";
585
+ uploadBtn.className = "cd-btn";
586
+ uploadBtn.setAttribute("aria-label", "Upload image attachment");
587
+ uploadBtn.innerHTML = `${ICON_IMAGE}<span>Image</span>`;
588
+ uploadBtn.addEventListener("click", () => this.fileInputEl?.click());
589
+ actions.appendChild(uploadBtn);
590
+
591
+ const submitBtn = document.createElement("button");
592
+ submitBtn.type = "button";
593
+ submitBtn.className = "cd-btn cd-btn-primary";
594
+ submitBtn.setAttribute("aria-label", "Submit feedback");
595
+ submitBtn.innerHTML = `${ICON_SEND}<span>Send</span>`;
596
+ submitBtn.addEventListener("click", () => {
597
+ void this.handleSubmit();
598
+ });
599
+ this.submitBtn = submitBtn;
600
+
601
+ toolbar.appendChild(actions);
602
+ toolbar.appendChild(submitBtn);
603
+ promptShell.appendChild(toolbar);
604
+ composer.appendChild(promptShell);
605
+
606
+ // 3. Freely Draggable Floating Pin Dock (visible when chatbox is closed, or draggable launcher)
607
+ const pinDock = document.createElement("div");
608
+ pinDock.className = "cd-pin-dock";
609
+ this.pinDockEl = pinDock;
610
+
611
+ const dockGrip = document.createElement("span");
612
+ dockGrip.className = "cd-pin-dock-grip";
613
+ dockGrip.title = "Drag to float anywhere on screen";
614
+ dockGrip.innerHTML = ICON_GRIP;
615
+ this.attachDragHandle(dockGrip);
616
+ pinDock.appendChild(dockGrip);
617
+
618
+ if (this.config.features.element_selection) {
619
+ const floatingPinBtn = document.createElement("button");
620
+ floatingPinBtn.type = "button";
621
+ floatingPinBtn.className = "cd-floating-pin-btn";
622
+ floatingPinBtn.title = "Pin an element on the page (or drag to move)";
623
+ floatingPinBtn.setAttribute("aria-label", "Pin element on page");
624
+ floatingPinBtn.innerHTML = `${ICON_PIN}<span>Pin</span>`;
625
+ this.attachDragHandle(floatingPinBtn, () => this.startPinSelection());
626
+ this.floatingPinBtn = floatingPinBtn;
627
+ pinDock.appendChild(floatingPinBtn);
628
+ }
629
+
630
+ const trigger = document.createElement("button");
631
+ trigger.type = "button";
632
+ trigger.className = "cd-trigger";
633
+ trigger.setAttribute("aria-label", "Open feedback composer");
634
+ trigger.setAttribute("aria-expanded", String(this.isOpen));
635
+ trigger.innerHTML = `${ICON_CHAT}<span>ContextDrop</span>`;
636
+ this.attachDragHandle(trigger, () => this.toggleComposer());
637
+ this.triggerBtn = trigger;
638
+ pinDock.appendChild(trigger);
639
+
640
+ root.appendChild(compactBar);
641
+ root.appendChild(composer);
642
+ root.appendChild(pinDock);
643
+ this.shadow.appendChild(root);
644
+
645
+ this.renderCompactBreadcrumbs(null, []);
646
+ this.syncUiStates();
647
+ }
648
+
649
+ private syncPinActiveIndicators(pickerActive: boolean): void {
650
+ if (this.cornerPinBtn) {
651
+ this.cornerPinBtn.classList.toggle("is-active", pickerActive);
652
+ this.cornerPinBtn.setAttribute("aria-pressed", String(pickerActive));
653
+ }
654
+ if (this.floatingPinBtn) {
655
+ this.floatingPinBtn.classList.toggle("is-active", pickerActive);
656
+ this.floatingPinBtn.setAttribute("aria-pressed", String(pickerActive));
657
+ }
658
+ if (this.compactPinBtn) {
659
+ this.compactPinBtn.classList.toggle("is-active", pickerActive);
660
+ this.compactPinBtn.innerHTML = pickerActive
661
+ ? `${ICON_PIN}<span>Pinning</span>`
662
+ : `${ICON_PIN}<span>Pin Element</span>`;
663
+ }
664
+ }
665
+
666
+ private syncUiStates(): void {
667
+ const pickerActive = this.picker ? this.picker.isActive() : false;
668
+ const totalContextCount = this.elementReferences.length + this.attachments.length;
669
+
670
+ if (this.composerEl) {
671
+ this.composerEl.hidden = !this.isOpen || this.isMinimized;
672
+ }
673
+ if (this.compactBarEl) {
674
+ this.compactBarEl.hidden = !(this.isOpen && this.isMinimized);
675
+ }
676
+ if (this.pinDockEl) {
677
+ this.pinDockEl.hidden = this.isOpen;
678
+ }
679
+
680
+ if (this.triggerBtn) {
681
+ this.triggerBtn.setAttribute("aria-expanded", String(this.isOpen && !this.isMinimized));
682
+ const countBadge =
683
+ totalContextCount > 0 ? `<span class="cd-count-pill">${totalContextCount}</span>` : "";
684
+ this.triggerBtn.innerHTML = `${ICON_CHAT}<span>ContextDrop</span>${countBadge}`;
685
+ }
686
+
687
+ if (this.cornerPinBtn) {
688
+ const pinCount =
689
+ this.elementReferences.length > 0
690
+ ? `<span class="cd-count-pill">${this.elementReferences.length}</span>`
691
+ : "";
692
+ this.cornerPinBtn.innerHTML = `${ICON_PIN}<span>Pin Element</span>${pinCount}`;
693
+ }
694
+
695
+ this.syncPinActiveIndicators(pickerActive);
696
+ }
697
+
698
+ private renderCompactBreadcrumbs(
699
+ activeCandidate: ElementCandidateInfo | null,
700
+ ancestors: Element[]
701
+ ): void {
702
+ if (!this.compactBreadcrumbEl) return;
703
+ this.compactBreadcrumbEl.innerHTML = "";
704
+
705
+ if (!activeCandidate || ancestors.length === 0) {
706
+ const hint = document.createElement("span");
707
+ hint.style.cssText = "font-size: 11.5px; color: #4b5262;";
708
+ hint.textContent =
709
+ this.elementReferences.length > 0
710
+ ? `${this.elementReferences.length} pinned · Hover or click any element (scroll ↕ for layers)`
711
+ : "Hover any element · Scroll ↕ for layers · Click to inspect stack";
712
+ this.compactBreadcrumbEl.appendChild(hint);
713
+ return;
714
+ }
715
+
716
+ ancestors.forEach((ancEl, idx) => {
717
+ if (idx > 0) {
718
+ const sep = document.createElement("span");
719
+ sep.className = "cd-crumb-sep";
720
+ sep.innerHTML = ICON_CHEVRON_RIGHT;
721
+ this.compactBreadcrumbEl?.appendChild(sep);
722
+ }
723
+
724
+ const isCurrent = ancEl === activeCandidate.element;
725
+ const crumb = document.createElement("button");
726
+ crumb.type = "button";
727
+ crumb.className = `cd-crumb-btn${isCurrent ? " is-current" : ""}`;
728
+ crumb.textContent = computeSelector(ancEl);
729
+ crumb.title = "Click to pin this element directly";
730
+ crumb.addEventListener("mouseenter", () => {
731
+ this.picker?.focusCandidateElement(ancEl);
732
+ });
733
+ crumb.addEventListener("click", () => {
734
+ this.picker?.selectElementDirectly(ancEl);
735
+ });
736
+ this.compactBreadcrumbEl?.appendChild(crumb);
737
+ });
738
+ }
739
+
740
+ private renderChatThread(): void {
741
+ if (!this.chatThreadEl) return;
742
+ this.chatThreadEl.innerHTML = "";
743
+
744
+ if (this.sessionHistory.length === 0) {
745
+ const emptyBox = document.createElement("div");
746
+ emptyBox.className = "cd-chat-empty";
747
+
748
+ const p1 = document.createElement("p");
749
+ p1.className = "cd-chat-empty-text";
750
+ p1.textContent = "Drop context directly from this page.";
751
+
752
+ const p2 = document.createElement("p");
753
+ p2.className = "cd-chat-empty-hint";
754
+ p2.textContent =
755
+ "Use the top-left Pin selector to pick specific HTML elements (scroll or click to disambiguate overlapping layers), attach screenshots, and send your note.";
756
+
757
+ emptyBox.appendChild(p1);
758
+ emptyBox.appendChild(p2);
759
+ this.chatThreadEl.appendChild(emptyBox);
760
+ return;
761
+ }
762
+
763
+ for (const entry of this.sessionHistory) {
764
+ const msgCard = document.createElement("div");
765
+ msgCard.className = "cd-chat-msg";
766
+
767
+ if (entry.elements.length > 0 || entry.attachments.length > 0) {
768
+ const pills = document.createElement("div");
769
+ pills.className = "cd-chat-msg-pills";
770
+
771
+ for (const el of entry.elements) {
772
+ const pill = document.createElement("span");
773
+ pill.className = "cd-chat-msg-pill";
774
+ pill.textContent = el.selector;
775
+ pills.appendChild(pill);
776
+ }
777
+ for (const att of entry.attachments) {
778
+ const pill = document.createElement("span");
779
+ pill.className = "cd-chat-msg-pill";
780
+ pill.textContent = att.label;
781
+ pills.appendChild(pill);
782
+ }
783
+ msgCard.appendChild(pills);
784
+ }
785
+
786
+ if (entry.message) {
787
+ const textP = document.createElement("p");
788
+ textP.className = "cd-chat-msg-text";
789
+ textP.textContent = entry.message;
790
+ msgCard.appendChild(textP);
791
+ }
792
+
793
+ const meta = document.createElement("div");
794
+ meta.className = "cd-chat-msg-meta";
795
+ const idSpan = document.createElement("span");
796
+ idSpan.textContent = `Dropped #${entry.id}`;
797
+ const timeSpan = document.createElement("span");
798
+ timeSpan.textContent = entry.createdAt;
799
+ meta.appendChild(idSpan);
800
+ meta.appendChild(timeSpan);
801
+ msgCard.appendChild(meta);
802
+
803
+ this.chatThreadEl.appendChild(msgCard);
804
+ }
805
+
806
+ this.chatThreadEl.scrollTop = this.chatThreadEl.scrollHeight;
807
+ }
808
+
809
+ private renderTray(): void {
810
+ if (!this.trayEl) return;
811
+ this.trayEl.innerHTML = "";
812
+
813
+ // Render ElementReference chips
814
+ for (const elRef of this.elementReferences) {
815
+ const chip = document.createElement("div");
816
+ chip.className = "cd-chip";
817
+ chip.setAttribute("data-chip-type", "element");
818
+
819
+ const domNode = this.elementDomNodes.get(elRef.client_ref);
820
+ if (domNode) {
821
+ chip.addEventListener("mouseenter", () => this.picker?.showExternalPreview(domNode));
822
+ chip.addEventListener("mouseleave", () => this.picker?.hideExternalPreview());
823
+ }
824
+
825
+ const badge = document.createElement("span");
826
+ badge.className = "cd-chip-badge";
827
+ badge.innerHTML = `${ICON_PIN}<span>Pin</span>`;
828
+
829
+ const label = document.createElement("span");
830
+ label.className = "cd-chip-label";
831
+ label.textContent = elRef.selector;
832
+ label.title = elRef.dom_path || elRef.selector;
833
+
834
+ chip.appendChild(badge);
835
+ chip.appendChild(label);
836
+
837
+ if (
838
+ this.config.features.screenshot_capture &&
839
+ domNode
840
+ ) {
841
+ const snapBtn = document.createElement("button");
842
+ snapBtn.type = "button";
843
+ snapBtn.className = "cd-chip-action";
844
+ snapBtn.title = "Capture element screenshot";
845
+ snapBtn.setAttribute("aria-label", `Capture screenshot of ${elRef.selector}`);
846
+ snapBtn.innerHTML = ICON_CAMERA;
847
+ snapBtn.addEventListener("click", () => {
848
+ void this.handleElementScreenshot(domNode, elRef.client_ref, elRef.selector);
849
+ });
850
+ chip.appendChild(snapBtn);
851
+ }
852
+
853
+ const removeBtn = document.createElement("button");
854
+ removeBtn.type = "button";
855
+ removeBtn.className = "cd-chip-remove";
856
+ removeBtn.setAttribute("aria-label", `Remove ${elRef.selector}`);
857
+ removeBtn.innerHTML = ICON_CLOSE;
858
+ removeBtn.addEventListener("click", () => this.removeElementReference(elRef.client_ref));
859
+
860
+ chip.appendChild(removeBtn);
861
+ this.trayEl.appendChild(chip);
862
+ }
863
+
864
+ // Render Attachment chips (uploaded images & screenshots)
865
+ for (const att of this.attachments) {
866
+ const chip = document.createElement("div");
867
+ chip.className = "cd-chip";
868
+ chip.setAttribute("data-chip-type", att.attachment_type);
869
+
870
+ if (att.preview_url) {
871
+ const img = document.createElement("img");
872
+ img.className = "cd-chip-thumb";
873
+ img.src = att.preview_url;
874
+ img.alt = att.label;
875
+ chip.appendChild(img);
876
+ }
877
+
878
+ const badge = document.createElement("span");
879
+ badge.className = "cd-chip-badge";
880
+ badge.textContent = att.attachment_type === "screenshot" ? "Shot" : "Image";
881
+
882
+ const label = document.createElement("span");
883
+ label.className = "cd-chip-label";
884
+ label.textContent = att.label;
885
+
886
+ const removeBtn = document.createElement("button");
887
+ removeBtn.type = "button";
888
+ removeBtn.className = "cd-chip-remove";
889
+ removeBtn.setAttribute("aria-label", `Remove ${att.label}`);
890
+ removeBtn.innerHTML = ICON_CLOSE;
891
+ removeBtn.addEventListener("click", () => this.removeAttachment(att.id));
892
+
893
+ chip.appendChild(badge);
894
+ chip.appendChild(label);
895
+ chip.appendChild(removeBtn);
896
+ this.trayEl.appendChild(chip);
897
+ }
898
+ }
899
+
900
+ private async handleFilesSelected(files: File[]): Promise<void> {
901
+ for (const file of files) {
902
+ if (this.attachments.length >= this.config.limits.max_attachments) {
903
+ this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
904
+ break;
905
+ }
906
+ if (!this.config.limits.allowed_content_types.includes(file.type)) {
907
+ this.showStatus(`Unsupported file type: ${file.type || file.name}`, "error");
908
+ continue;
909
+ }
910
+ if (file.size > this.config.limits.max_attachment_size) {
911
+ this.showStatus(`File ${file.name} exceeds size limit.`, "error");
912
+ continue;
913
+ }
914
+
915
+ const dataUrl = await this.readFileAsDataUrl(file);
916
+ this.attachments.push({
917
+ id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
918
+ attachment_type: "image",
919
+ label: file.name,
920
+ preview_url: dataUrl,
921
+ blob: file,
922
+ data_url: dataUrl,
923
+ filename: file.name,
924
+ metadata: {
925
+ original_filename: file.name,
926
+ byte_size: file.size
927
+ }
928
+ });
929
+ }
930
+ this.renderTray();
931
+ this.syncUiStates();
932
+ }
933
+
934
+ private handlePaste(event: ClipboardEvent): void {
935
+ const items = event.clipboardData?.items;
936
+ if (!items) return;
937
+
938
+ const imageFiles: File[] = [];
939
+ for (let i = 0; i < items.length; i += 1) {
940
+ const item = items[i];
941
+ if (item.type.startsWith("image/")) {
942
+ const file = item.getAsFile();
943
+ if (file) imageFiles.push(file);
944
+ }
945
+ }
946
+ if (imageFiles.length > 0) {
947
+ void this.handleFilesSelected(imageFiles);
948
+ }
949
+ }
950
+
951
+ private readFileAsDataUrl(file: Blob): Promise<string> {
952
+ return new Promise((resolve, reject) => {
953
+ const reader = new FileReader();
954
+ reader.onload = () => resolve(String(reader.result || ""));
955
+ reader.onerror = () => reject(new Error("Failed to read image file"));
956
+ reader.readAsDataURL(file);
957
+ });
958
+ }
959
+
960
+ private async handleViewportScreenshot(): Promise<void> {
961
+ if (this.attachments.length >= this.config.limits.max_attachments) {
962
+ this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
963
+ return;
964
+ }
965
+
966
+ try {
967
+ const result = await captureViewportScreenshot(this.config.privacy.excluded_selectors);
968
+ this.attachments.push({
969
+ id: `shot_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
970
+ attachment_type: "screenshot",
971
+ label: `Viewport (${result.width}×${result.height})`,
972
+ preview_url: result.dataUrl,
973
+ blob: result.blob,
974
+ data_url: result.dataUrl,
975
+ filename: `viewport-${Date.now()}.png`,
976
+ metadata: result.metadata
977
+ });
978
+ this.renderTray();
979
+ this.syncUiStates();
980
+ } catch (err) {
981
+ const message = err instanceof Error ? err.message : "Screenshot capture unavailable.";
982
+ this.showStatus(message, "error");
983
+ }
984
+ }
985
+
986
+ private async handleElementScreenshot(
987
+ element: Element,
988
+ elementClientRef: string,
989
+ selectorLabel: string
990
+ ): Promise<void> {
991
+ if (this.attachments.length >= this.config.limits.max_attachments) {
992
+ this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
993
+ return;
994
+ }
995
+
996
+ try {
997
+ const result = await captureElementScreenshot(
998
+ element,
999
+ this.config.features.element_capture_padding,
1000
+ this.config.privacy.excluded_selectors,
1001
+ elementClientRef
1002
+ );
1003
+ this.attachments.push({
1004
+ id: `elshot_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
1005
+ attachment_type: "screenshot",
1006
+ label: `Element ${selectorLabel}`,
1007
+ preview_url: result.dataUrl,
1008
+ blob: result.blob,
1009
+ data_url: result.dataUrl,
1010
+ filename: `element-${Date.now()}.png`,
1011
+ element_reference_ref: elementClientRef,
1012
+ metadata: result.metadata
1013
+ });
1014
+ this.renderTray();
1015
+ this.syncUiStates();
1016
+ } catch (err) {
1017
+ const message = err instanceof Error ? err.message : "Element screenshot failed.";
1018
+ this.showStatus(message, "error");
1019
+ }
1020
+ }
1021
+
1022
+ public async handleSubmit(): Promise<void> {
1023
+ if (this.isSubmitting) return;
1024
+
1025
+ const message = (this.textareaEl?.value || "").trim();
1026
+ if (
1027
+ message.length === 0 &&
1028
+ this.attachments.length === 0 &&
1029
+ this.elementReferences.length === 0
1030
+ ) {
1031
+ this.showStatus("Enter a note, pin an element, or attach a screenshot first.", "error");
1032
+ return;
1033
+ }
1034
+
1035
+ this.isSubmitting = true;
1036
+ if (this.submitBtn) {
1037
+ this.submitBtn.disabled = true;
1038
+ this.submitBtn.innerHTML = `<span>Sending...</span>`;
1039
+ }
1040
+
1041
+ try {
1042
+ const submittedElements = [...this.elementReferences];
1043
+ const submittedAttachments = this.attachments.map((att) => ({
1044
+ id: att.id,
1045
+ label: att.label,
1046
+ preview_url: att.preview_url,
1047
+ attachment_type: att.attachment_type
1048
+ }));
1049
+
1050
+ const payload: FeedbackSubmissionPayload = {
1051
+ message,
1052
+ page_url: typeof window !== "undefined" ? window.location.href : "/",
1053
+ user_agent: typeof navigator !== "undefined" ? navigator.userAgent : "",
1054
+ metadata: {
1055
+ ...this.customMetadata,
1056
+ viewport: {
1057
+ width: typeof window !== "undefined" ? window.innerWidth : 0,
1058
+ height: typeof window !== "undefined" ? window.innerHeight : 0
1059
+ },
1060
+ locale: typeof navigator !== "undefined" ? navigator.language : "en"
1061
+ },
1062
+ element_references: this.elementReferences,
1063
+ attachments: this.attachments.map((att) => ({
1064
+ attachment_type: att.attachment_type,
1065
+ filename: att.filename,
1066
+ signed_blob_id: att.signed_blob_id,
1067
+ data_url: att.signed_blob_id ? undefined : att.data_url,
1068
+ element_reference_ref: att.element_reference_ref,
1069
+ metadata: att.metadata
1070
+ }))
1071
+ };
1072
+
1073
+ const res = await this.apiClient.submitFeedback(payload);
1074
+
1075
+ const now = new Date();
1076
+ const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
1077
+ this.sessionHistory.push({
1078
+ id: res?.id ?? this.sessionHistory.length + 1,
1079
+ message: message || "(Context drop without text)",
1080
+ createdAt: timeLabel,
1081
+ elements: submittedElements,
1082
+ attachments: submittedAttachments
1083
+ });
1084
+
1085
+ if (this.textareaEl) {
1086
+ this.textareaEl.value = "";
1087
+ }
1088
+ this.elementReferences = [];
1089
+ this.elementDomNodes.clear();
1090
+ this.attachments = [];
1091
+ this.picker?.hideExternalPreview();
1092
+ this.renderTray();
1093
+ this.renderChatThread();
1094
+ this.syncUiStates();
1095
+ this.showStatus("Context drop saved.", "success");
1096
+ } catch (err) {
1097
+ const msg = err instanceof Error ? err.message : "Failed to submit feedback.";
1098
+ this.showStatus(msg, "error");
1099
+ } finally {
1100
+ this.isSubmitting = false;
1101
+ if (this.submitBtn) {
1102
+ this.submitBtn.disabled = false;
1103
+ this.submitBtn.innerHTML = `${ICON_SEND}<span>Send</span>`;
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ private showStatus(text: string, kind: "error" | "success"): void {
1109
+ if (!this.statusEl) return;
1110
+ this.statusEl.textContent = text;
1111
+ this.statusEl.className = `cd-status ${kind}`;
1112
+ this.statusEl.hidden = false;
1113
+ }
1114
+ }
1115
+
1116
+ export function registerContextDropWidget(): void {
1117
+ if (typeof customElements !== "undefined" && !customElements.get("context-drop-widget")) {
1118
+ customElements.define("context-drop-widget", ContextDropWidgetElement);
1119
+ }
1120
+ }