@kodaris/krubble-app-components 1.0.93 → 1.0.95

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.
@@ -677,14 +677,6 @@
677
677
  handleNavScroll(e) {
678
678
  this.isNavScrolled = e.target.scrollTop > 0;
679
679
  }
680
- /**
681
- * Toggles the expanded/collapsed state of a nav group with animation.
682
- * Uses the Web Animations API to animate height transitions.
683
- * When collapsing, the `nav-group--expanded` class is removed after animation completes.
684
- * When expanding, the class is added immediately before animation starts.
685
- *
686
- * @param itemId - The unique identifier of the nav group to toggle
687
- */
688
680
  /**
689
681
  * Expand a nav group by id. Public so consumers can keep a group open after
690
682
  * adding it dynamically — the scaffold only auto-expands groups once at startup,
@@ -698,37 +690,6 @@
698
690
  this.navItemsExpanded.add(groupId);
699
691
  this.requestUpdate();
700
692
  }
701
- toggleNavItem(itemId) {
702
- const navItem = this.shadowRoot?.querySelector(`.nav-item[data-id="${itemId}"]`);
703
- const groupEl = navItem?.nextElementSibling;
704
- if (!groupEl) {
705
- return;
706
- }
707
- if (this.navItemsExpanded.has(itemId)) {
708
- this.navItemsExpanded.delete(itemId);
709
- groupEl.animate([
710
- { height: `${groupEl.scrollHeight}px` },
711
- { height: '0px' }
712
- ], {
713
- duration: 300,
714
- easing: 'ease-in-out'
715
- }).onfinish = () => {
716
- groupEl.classList.remove('nav-group--expanded');
717
- };
718
- }
719
- else {
720
- this.navItemsExpanded.add(itemId);
721
- groupEl.classList.add('nav-group--expanded');
722
- groupEl.animate([
723
- { height: '0px' },
724
- { height: `${groupEl.scrollHeight}px` }
725
- ], {
726
- duration: 300,
727
- easing: 'ease-in-out'
728
- });
729
- }
730
- this.requestUpdate();
731
- }
732
693
  /**
733
694
  * Handles click events on navigation items.
734
695
  *
@@ -749,7 +710,38 @@
749
710
  */
750
711
  handleNavItemClick(e, item) {
751
712
  if (item.type === 'group') {
752
- this.toggleNavItem(item.id);
713
+ // Animate the group's height with the Web Animations API. Collapsing removes
714
+ // `nav-group--expanded` only once the animation finishes; expanding adds it
715
+ // up front so there's a height to animate from.
716
+ const navItem = this.shadowRoot?.querySelector(`.nav-item[data-id="${item.id}"]`);
717
+ const groupEl = navItem?.nextElementSibling;
718
+ if (!groupEl) {
719
+ return;
720
+ }
721
+ if (this.navItemsExpanded.has(item.id)) {
722
+ this.navItemsExpanded.delete(item.id);
723
+ groupEl.animate([
724
+ { height: `${groupEl.scrollHeight}px` },
725
+ { height: '0px' }
726
+ ], {
727
+ duration: 300,
728
+ easing: 'ease-in-out'
729
+ }).onfinish = () => {
730
+ groupEl.classList.remove('nav-group--expanded');
731
+ };
732
+ }
733
+ else {
734
+ this.navItemsExpanded.add(item.id);
735
+ groupEl.classList.add('nav-group--expanded');
736
+ groupEl.animate([
737
+ { height: '0px' },
738
+ { height: `${groupEl.scrollHeight}px` }
739
+ ], {
740
+ duration: 300,
741
+ easing: 'ease-in-out'
742
+ });
743
+ }
744
+ this.requestUpdate();
753
745
  }
754
746
  else {
755
747
  // Allow Ctrl/Cmd+click and middle-click to open in new tab without SPA navigation
@@ -808,18 +800,15 @@
808
800
  // =========================================================================
809
801
  // Navigation Customization
810
802
  // =========================================================================
811
- startEditing() {
812
- this.isEditing = true;
813
- }
814
- cancelEditing() {
803
+ handleEditCancel() {
815
804
  this.isEditing = false;
816
805
  // Reload to revert changes
817
806
  this.loadPref();
818
807
  }
819
- resetPref() {
808
+ handlePrefReset() {
820
809
  this.pref.nav = {};
821
810
  }
822
- savePref() {
811
+ handlePrefSave() {
823
812
  if (this.userType === 'customer') {
824
813
  return;
825
814
  }
@@ -1119,9 +1108,17 @@
1119
1108
  else {
1120
1109
  // Middle third - drop into the group (as a child)
1121
1110
  position = 'center';
1122
- // Auto-expand collapsed groups after a delay
1111
+ // Auto-expand collapsed groups after a delay. The 500ms wait keeps groups
1112
+ // from expanding too eagerly when the user is just passing over them, and
1113
+ // the check inside confirms they're still on this group's center.
1123
1114
  if (!this.navItemsExpanded.has(item.id) && !this.navItemDragExpandTimeout) {
1124
- this.expandNavGroupOnDrag(item.id);
1115
+ this.navItemDragExpandTimeout = window.setTimeout(() => {
1116
+ if (this.navItemDropTargetId === item.id && this.navItemDropPosition === 'center') {
1117
+ this.navItemsExpanded.add(item.id);
1118
+ this.requestUpdate();
1119
+ }
1120
+ this.navItemDragExpandTimeout = null;
1121
+ }, 500);
1125
1122
  }
1126
1123
  }
1127
1124
  }
@@ -1176,24 +1173,6 @@
1176
1173
  this.navItemDragExpandTimeout = null;
1177
1174
  }
1178
1175
  }
1179
- /**
1180
- * Auto-expands a collapsed nav group after a delay while dragging over it.
1181
- *
1182
- * Uses a 500ms timeout to prevent groups from expanding too eagerly when the
1183
- * user is just passing over them. Only expands if the user is still hovering
1184
- * over the group's center when the timeout fires.
1185
- *
1186
- * @param itemId - The ID of the group to expand
1187
- */
1188
- expandNavGroupOnDrag(itemId) {
1189
- this.navItemDragExpandTimeout = window.setTimeout(() => {
1190
- if (this.navItemDropTargetId === itemId && this.navItemDropPosition === 'center') {
1191
- this.navItemsExpanded.add(itemId);
1192
- this.requestUpdate();
1193
- }
1194
- this.navItemDragExpandTimeout = null;
1195
- }, 500);
1196
- }
1197
1176
  /**
1198
1177
  * Executes the drop operation after a nav item drag completes.
1199
1178
  *
@@ -1281,15 +1260,12 @@
1281
1260
  // =========================================================================
1282
1261
  // User Menu
1283
1262
  // =========================================================================
1284
- toggleUserMenu() {
1263
+ handleUserMenuToggle() {
1285
1264
  this.isUserMenuOpen = !this.isUserMenuOpen;
1286
1265
  }
1287
- closeUserMenu() {
1288
- this.isUserMenuOpen = false;
1289
- }
1290
1266
  handleCustomize() {
1291
- this.closeUserMenu();
1292
- this.startEditing();
1267
+ this.isUserMenuOpen = false;
1268
+ this.isEditing = true;
1293
1269
  }
1294
1270
  // =========================================================================
1295
1271
  // Rendering
@@ -1314,7 +1290,7 @@
1314
1290
  </button>
1315
1291
  </div>
1316
1292
  ` : A}
1317
- <div class="user" @click=${this.toggleUserMenu}>
1293
+ <div class="user" @click=${this.handleUserMenuToggle}>
1318
1294
  <div class="user__avatar">
1319
1295
  ${this.user.avatar
1320
1296
  ? b `<img src=${this.user.avatar} alt=${this.user.name} />`
@@ -1332,14 +1308,14 @@
1332
1308
  renderEditFooter() {
1333
1309
  return b `
1334
1310
  <div class="edit-actions">
1335
- <button class="edit-actions__btn edit-actions__btn--primary" @click=${this.savePref}>
1311
+ <button class="edit-actions__btn edit-actions__btn--primary" @click=${this.handlePrefSave}>
1336
1312
  Save
1337
1313
  </button>
1338
1314
  <div class="edit-actions__secondary">
1339
- <button class="edit-actions__btn" @click=${this.resetPref}>
1315
+ <button class="edit-actions__btn" @click=${this.handlePrefReset}>
1340
1316
  Reset
1341
1317
  </button>
1342
- <button class="edit-actions__btn" @click=${this.cancelEditing}>
1318
+ <button class="edit-actions__btn" @click=${this.handleEditCancel}>
1343
1319
  Cancel
1344
1320
  </button>
1345
1321
  </div>
@@ -2738,35 +2714,6 @@
2738
2714
  composed: true
2739
2715
  }));
2740
2716
  }
2741
- /**
2742
- * Resolves a breadcrumb URL by prepending the base href.
2743
- *
2744
- * This method is used for setting href attributes on anchor elements
2745
- * (so Cmd/Ctrl+click opens correct URL in new tab).
2746
- *
2747
- * The original crumb.url is kept intact (without base href) and passed to Angular
2748
- * via the breadcrumb-click event. Angular Router expects paths relative to base,
2749
- * so it handles the base href internally.
2750
- *
2751
- * Example with base href "/operations/":
2752
- * - crumb.url = "/settings" (used for Angular routing)
2753
- * - resolveUrl(crumb) = "/operations/settings" (used for href)
2754
- */
2755
- resolveUrl(crumb) {
2756
- if (!crumb.url) {
2757
- return '#';
2758
- }
2759
- // Remove leading slash from url if present, then combine with base
2760
- let path = crumb.url;
2761
- if (crumb.url.startsWith('/')) {
2762
- path = crumb.url.slice(1);
2763
- }
2764
- const baseHref = document.querySelector('base')?.getAttribute('href') || '/';
2765
- if (baseHref.endsWith('/')) {
2766
- return baseHref + path;
2767
- }
2768
- return baseHref + '/' + path;
2769
- }
2770
2717
  /**
2771
2718
  * Handles click on a breadcrumb link.
2772
2719
  * Dispatches a cancelable event so SPA routers can intercept.
@@ -2828,6 +2775,23 @@
2828
2775
  <div class="breadcrumbs">
2829
2776
  ${this.breadcrumbs.map((crumb, index) => {
2830
2777
  const isCurrent = index === this.breadcrumbs.length - 1;
2778
+ // The href gets the base href prepended so Cmd/Ctrl+click opens the right
2779
+ // URL in a new tab. crumb.url itself is left intact (base-relative) and
2780
+ // passed to Angular via breadcrumb-click — its Router applies the base
2781
+ // itself. e.g. base "/operations/" + crumb.url "/settings" ->
2782
+ // "/operations/settings".
2783
+ const baseHref = document.querySelector('base')?.getAttribute('href') || '/';
2784
+ let href = '#';
2785
+ if (crumb.url) {
2786
+ let path = crumb.url;
2787
+ if (crumb.url.startsWith('/')) {
2788
+ path = crumb.url.slice(1);
2789
+ }
2790
+ href = baseHref + path;
2791
+ if (!baseHref.endsWith('/')) {
2792
+ href = baseHref + '/' + path;
2793
+ }
2794
+ }
2831
2795
  return b `
2832
2796
  ${index > 0 ? b `<span class="breadcrumb-separator">/</span>` : ''}
2833
2797
  <a
@@ -2835,7 +2799,7 @@
2835
2799
  'breadcrumb': true,
2836
2800
  'breadcrumb--current': isCurrent
2837
2801
  })}
2838
- href=${this.resolveUrl(crumb)}
2802
+ href=${href}
2839
2803
  @click=${(e) => this._handleBreadcrumbClick(e, crumb, isCurrent)}
2840
2804
  >${crumb.label}</a>
2841
2805
  `;
@@ -5567,8 +5531,18 @@
5567
5531
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5568
5532
  return c > 3 && r && Object.defineProperty(target, key, r), r;
5569
5533
  };
5570
- /** Image MIME types the chatbot accepts (matches what Bedrock can ingest). */
5571
- const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
5534
+ /** Image extensions the chatbot renders as thumbnails (used when MIME is absent). */
5535
+ const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
5536
+ /**
5537
+ * File extensions the picker accepts. Mirrors what the backend can read: the
5538
+ * document/spreadsheet family Bedrock ingests, the plain-text family read as
5539
+ * fallback, and the image formats. This is a UX convenience — the backend
5540
+ * accepts anything and just tells the model when a type can't be read.
5541
+ */
5542
+ const ACCEPTED_FILE_EXTENSIONS = [
5543
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'html', 'md', 'txt', 'json', 'xml',
5544
+ ...IMAGE_EXTENSIONS,
5545
+ ];
5572
5546
  /**
5573
5547
  * AI chatbot component with SSE streaming.
5574
5548
  *
@@ -5633,6 +5607,13 @@
5633
5607
  this._dragDepth = 0;
5634
5608
  /** Monotonic id source for pending attachments. */
5635
5609
  this._pendingSeq = 0;
5610
+ /**
5611
+ * The `aiAnalyze` mode for the next picked files — set when the attach action
5612
+ * opens the picker (true = "Attach Files", false = "Attach Files for
5613
+ * Reference"), read by the file-input change handler. Not reactive: it's read
5614
+ * synchronously in the change handler, never rendered.
5615
+ */
5616
+ this._pendingAttachAnalyze = true;
5636
5617
  /**
5637
5618
  * Request-id guards for skill searches — bumped on each search so out-of-order
5638
5619
  * responses are dropped (mirrors the kr-combo-box `_requestId` pattern). Org and
@@ -5749,7 +5730,7 @@
5749
5730
  this._debugDrawerWidth = 430;
5750
5731
  /** True while the user is dragging the debug drawer's resize handle. */
5751
5732
  this._isDebugResizing = false;
5752
- /** Images staged in the composer, before the message is sent. */
5733
+ /** Files staged in the composer, before the message is sent. */
5753
5734
  this._pendingConversationFiles = [];
5754
5735
  /** True while a file is being dragged over the panel (shows the drop overlay). */
5755
5736
  this._isDraggingFile = false;
@@ -5848,7 +5829,9 @@
5848
5829
  this._isResizing = false;
5849
5830
  this._panelEl.classList.remove('panel--resizing');
5850
5831
  }
5851
- this._hideOverlay();
5832
+ if (this._overlay) {
5833
+ this._overlay.style.display = 'none';
5834
+ }
5852
5835
  document.removeEventListener('mousemove', this._handleMouseMove);
5853
5836
  document.removeEventListener('mouseup', this._handleMouseUp);
5854
5837
  this._saveState();
@@ -5947,7 +5930,11 @@
5947
5930
  document.removeEventListener('mousemove', this._handleMouseMove);
5948
5931
  document.removeEventListener('mouseup', this._handleMouseUp);
5949
5932
  // Release any object URLs still held by staged attachments.
5950
- this._pendingConversationFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
5933
+ this._pendingConversationFiles.forEach((p) => {
5934
+ if (p.previewUrl) {
5935
+ URL.revokeObjectURL(p.previewUrl);
5936
+ }
5937
+ });
5951
5938
  }
5952
5939
  updated(changed) {
5953
5940
  if (changed.has('messages')) {
@@ -6052,11 +6039,6 @@
6052
6039
  }
6053
6040
  this._overlay.style.display = 'block';
6054
6041
  }
6055
- _hideOverlay() {
6056
- if (this._overlay) {
6057
- this._overlay.style.display = 'none';
6058
- }
6059
- }
6060
6042
  _handleHeaderMouseDown(e) {
6061
6043
  // Inline mode fills its parent — dragging the panel doesn't apply.
6062
6044
  if (this.variant === 'inline') {
@@ -6226,13 +6208,14 @@
6226
6208
  }
6227
6209
  _handleKeyDown(e) {
6228
6210
  // When the skill menu is open, arrow keys / Enter / Escape drive it. The top
6229
- // actions (Attach Files, Browse All Skills, plus the two debug test triggers
6230
- // when enabled) come first, then the filtered skills — all one navigable list.
6211
+ // actions (Attach Files, Attach Files for Reference, Browse All Skills, plus
6212
+ // the two debug test triggers when enabled) come first, then the filtered
6213
+ // skills — all one navigable list.
6231
6214
  if (this._skillMenuOpened) {
6232
6215
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
6233
- let actionCount = 2;
6216
+ let actionCount = 3;
6234
6217
  if (debugActions) {
6235
- actionCount = 4;
6218
+ actionCount = 5;
6236
6219
  }
6237
6220
  const count = actionCount + this._invocableSkills.length;
6238
6221
  if (e.key === 'ArrowDown') {
@@ -6264,19 +6247,23 @@
6264
6247
  /** Run the skill-menu item at the given flat index (actions first, then skills). */
6265
6248
  _activateSkillMenuItem(index) {
6266
6249
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
6267
- let actionCount = 2;
6250
+ let actionCount = 3;
6268
6251
  if (debugActions) {
6269
- actionCount = 4;
6252
+ actionCount = 5;
6270
6253
  }
6271
6254
  if (index === 0) {
6272
- this._openFilePicker();
6255
+ this._openFilePicker(true);
6273
6256
  return;
6274
6257
  }
6275
6258
  if (index === 1) {
6259
+ this._openFilePicker(false);
6260
+ return;
6261
+ }
6262
+ if (index === 2) {
6276
6263
  this._handleSkillBrowseOpen();
6277
6264
  return;
6278
6265
  }
6279
- if (debugActions && index === 2) {
6266
+ if (debugActions && index === 3) {
6280
6267
  this._skillMenuOpened = false;
6281
6268
  this._send({
6282
6269
  message: '/test-context-warning',
@@ -6284,7 +6271,7 @@
6284
6271
  });
6285
6272
  return;
6286
6273
  }
6287
- if (debugActions && index === 3) {
6274
+ if (debugActions && index === 4) {
6288
6275
  this._skillMenuOpened = false;
6289
6276
  this._send({
6290
6277
  message: '/test-context-limit',
@@ -6604,17 +6591,26 @@
6604
6591
  this._skillBrowseSource = e.detail.activeTabId;
6605
6592
  }
6606
6593
  // --- Attachments ---
6607
- _openFilePicker() {
6594
+ /**
6595
+ * Open the OS file picker. `aiAnalyze` records which attach action was used so
6596
+ * the change handler stages the picked files in the right mode: true for
6597
+ * "Attach Files" (the model reads them), false for "Attach Files for Reference"
6598
+ * (attached for a tool to act on — the model is told about them but not asked
6599
+ * to read them). Chosen once at the picker, so selecting 30 files never means
6600
+ * toggling 30 chips.
6601
+ */
6602
+ _openFilePicker(aiAnalyze = true) {
6608
6603
  // Close the skill/attachment menu — opening the OS file picker is the end of
6609
6604
  // the menu interaction, and the picker is a separate window so the menu would
6610
6605
  // otherwise linger open behind it (and after a file is chosen).
6611
6606
  this._skillMenuOpened = false;
6607
+ this._pendingAttachAnalyze = aiAnalyze;
6612
6608
  this._fileInputEl?.click();
6613
6609
  }
6614
6610
  _handleFileInputChange(e) {
6615
6611
  const input = e.target;
6616
6612
  if (input.files) {
6617
- this._addFiles(input.files);
6613
+ this._addFiles(input.files, this._pendingAttachAnalyze);
6618
6614
  }
6619
6615
  // Reset so picking the same file again re-fires change.
6620
6616
  input.value = '';
@@ -6677,35 +6673,62 @@
6677
6673
  _hasFiles(e) {
6678
6674
  return Array.from(e.dataTransfer?.types || []).includes('Files');
6679
6675
  }
6680
- /** Stage a set of image files in the composer (held locally until send). */
6681
- _addFiles(fileList) {
6682
- const files = Array.from(fileList).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type));
6676
+ /** Lower-cased extension of a filename (without the dot), or '' if none. */
6677
+ _getFileExtension(name) {
6678
+ const dot = name.lastIndexOf('.');
6679
+ if (dot < 0) {
6680
+ return '';
6681
+ }
6682
+ return name.slice(dot + 1).toLowerCase();
6683
+ }
6684
+ /**
6685
+ * Stage a set of files in the composer (held locally until send). `aiAnalyze`
6686
+ * comes from which attach action was used — true for "Attach Files" (the model
6687
+ * reads them), false for "Attach Files for Reference" (tool-only, not read).
6688
+ * Drag-and-drop and paste default to analyze.
6689
+ */
6690
+ _addFiles(fileList, aiAnalyze = true) {
6691
+ // Only accept what the picker's `accept` filter allows (extension-based, so a
6692
+ // drag/paste can't sneak in a type the picker itself wouldn't offer).
6693
+ const files = Array.from(fileList).filter((f) => ACCEPTED_FILE_EXTENSIONS.includes(this._getFileExtension(f.name)));
6683
6694
  if (!files.length) {
6684
6695
  return;
6685
6696
  }
6686
6697
  files.forEach((file) => {
6698
+ // Only images get a local preview thumbnail; documents render an icon.
6699
+ // Prefer the File's MIME, falling back to the extension when it's absent.
6700
+ let previewUrl;
6701
+ if (file.type.startsWith('image/') || IMAGE_EXTENSIONS.includes(this._getFileExtension(file.name))) {
6702
+ previewUrl = URL.createObjectURL(file);
6703
+ }
6687
6704
  const pending = {
6688
6705
  localId: 'att-' + this._pendingSeq++,
6689
6706
  file,
6690
6707
  name: file.name,
6691
- previewUrl: URL.createObjectURL(file),
6708
+ mimeType: file.type,
6709
+ previewUrl,
6710
+ aiAnalyze,
6692
6711
  };
6693
6712
  this._pendingConversationFiles = [...this._pendingConversationFiles, pending];
6694
6713
  });
6695
6714
  }
6696
- _removePending(localId) {
6715
+ _handlePendingRemove(localId) {
6697
6716
  const target = this._pendingConversationFiles.find((p) => p.localId === localId);
6698
- if (target) {
6717
+ if (target?.previewUrl) {
6699
6718
  URL.revokeObjectURL(target.previewUrl);
6700
6719
  }
6701
6720
  this._pendingConversationFiles = this._pendingConversationFiles.filter((p) => p.localId !== localId);
6702
6721
  }
6703
6722
  _clearPending() {
6704
- this._pendingConversationFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
6723
+ this._pendingConversationFiles.forEach((p) => {
6724
+ if (p.previewUrl) {
6725
+ URL.revokeObjectURL(p.previewUrl);
6726
+ }
6727
+ });
6705
6728
  this._pendingConversationFiles = [];
6706
6729
  }
6707
6730
  /** Open the shared full-screen previewer for a message image. */
6708
- _openImage(img) {
6731
+ _handleImageOpen(img) {
6709
6732
  // Use the local preview if the upload hasn't finished yet; otherwise the
6710
6733
  // backend view URL (prefixed with the base URL — a raw fetch of a relative
6711
6734
  // path would hit the page origin, e.g. the dev server, not the backend).
@@ -6722,10 +6745,6 @@
6722
6745
  window.open(`${krubbleHttp.Http.getBaseUrl()}/api/system/conversationFile/${img.conversationFileID}/view?download=true`, '_blank');
6723
6746
  });
6724
6747
  }
6725
- /** Whether the send button is active: needs text or at least one image. */
6726
- _canSend() {
6727
- return Boolean(this._inputValue.trim()) || this._pendingConversationFiles.length > 0;
6728
- }
6729
6748
  _handleSubmit() {
6730
6749
  if (this._isStreaming) {
6731
6750
  return;
@@ -6736,10 +6755,13 @@
6736
6755
  if (!messageText && !pending.length) {
6737
6756
  return;
6738
6757
  }
6739
- // The raw files sent to the backend. Sent images are shown once the
6740
- // conversation reloads and they come back as stored conversation files; the
6741
- // just-sent message itself carries text only.
6742
- const conversationFiles = pending.map((p) => p.file);
6758
+ // The files sent to the backend, each paired with its analyze flag. Sent
6759
+ // files are shown once the conversation reloads and they come back as stored
6760
+ // conversation files; the just-sent message itself carries text only.
6761
+ const conversationFiles = pending.map((p) => ({
6762
+ file: p.file,
6763
+ aiAnalyze: p.aiAnalyze,
6764
+ }));
6743
6765
  const submitEvent = new CustomEvent('message-submit', {
6744
6766
  detail: {
6745
6767
  message: messageText,
@@ -6771,12 +6793,15 @@
6771
6793
  };
6772
6794
  // Attach the staged files' local previews to the message so its thumbnails
6773
6795
  // show INSTANTLY, without waiting on the upload. Each carries a temporary
6774
- // previewUrl (and conversationFileID -1 as a placeholder); the built-in send
6775
- // swaps these for the real server files once the upload returns.
6796
+ // previewUrl (images only; documents render an icon from mimeType) and
6797
+ // conversationFileID -1 as a placeholder; the built-in send swaps these for
6798
+ // the real server files once the upload returns.
6776
6799
  if (this._pendingConversationFiles.length) {
6777
6800
  userMessage.conversationFiles = this._pendingConversationFiles.map((p) => ({
6778
6801
  conversationFileID: -1,
6779
6802
  niceName: p.name,
6803
+ mimeType: p.mimeType,
6804
+ aiAnalyze: p.aiAnalyze,
6780
6805
  previewUrl: p.previewUrl,
6781
6806
  }));
6782
6807
  }
@@ -6831,11 +6856,17 @@
6831
6856
  // upload is dropped so an undefined id never reaches `fileIds`.
6832
6857
  let uploaded = Promise.resolve([]);
6833
6858
  if (params.conversationFiles.length) {
6834
- uploaded = Promise.all(params.conversationFiles.map((file) => {
6859
+ uploaded = Promise.all(params.conversationFiles.map(({ file, aiAnalyze }) => {
6835
6860
  const form = new FormData();
6836
6861
  form.append('file', file);
6837
6862
  form.append('type', 'LIBRARY');
6838
6863
  form.append('niceName', file.name);
6864
+ // Reference-only files (from "Attach Files for Reference") aren't read
6865
+ // by the model — the endpoint defaults aiAnalyze=true, so only send
6866
+ // the flag when it's false.
6867
+ if (!aiAnalyze) {
6868
+ form.append('aiAnalyze', 'false');
6869
+ }
6839
6870
  return krubbleHttp.Http.fetch({
6840
6871
  url: '/api/system/conversationFile/upload',
6841
6872
  method: 'POST',
@@ -7117,9 +7148,9 @@
7117
7148
  // flat keyboard index counts actions first, then skills, so the skills'
7118
7149
  // index offset is the number of actions shown.
7119
7150
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
7120
- let actionCount = 2;
7151
+ let actionCount = 3;
7121
7152
  if (debugActions) {
7122
- actionCount = 4;
7153
+ actionCount = 5;
7123
7154
  }
7124
7155
  return b `
7125
7156
  <div class="skill-menu">
@@ -7129,14 +7160,25 @@
7129
7160
  'skill-menu__action': true,
7130
7161
  'skill-menu__item--highlighted': this._skillHighlighted === 0,
7131
7162
  })}
7132
- @click=${this._openFilePicker}
7163
+ @click=${() => this._openFilePicker(true)}
7133
7164
  >
7134
- Attach Files
7165
+ <span class="skill-menu__name">Attach Files</span>
7166
+ <span class="skill-menu__desc">The AI reads these files</span>
7135
7167
  </button>
7136
7168
  <button
7137
7169
  class=${e({
7138
7170
  'skill-menu__action': true,
7139
7171
  'skill-menu__item--highlighted': this._skillHighlighted === 1,
7172
+ })}
7173
+ @click=${() => this._openFilePicker(false)}
7174
+ >
7175
+ <span class="skill-menu__name">Attach Files for Reference</span>
7176
+ <span class="skill-menu__desc">Attached for a tool — not read by the AI</span>
7177
+ </button>
7178
+ <button
7179
+ class=${e({
7180
+ 'skill-menu__action': true,
7181
+ 'skill-menu__item--highlighted': this._skillHighlighted === 2,
7140
7182
  })}
7141
7183
  @click=${this._handleSkillBrowseOpen}
7142
7184
  >
@@ -7146,18 +7188,18 @@
7146
7188
  <button
7147
7189
  class=${e({
7148
7190
  'skill-menu__action': true,
7149
- 'skill-menu__item--highlighted': this._skillHighlighted === 2,
7191
+ 'skill-menu__item--highlighted': this._skillHighlighted === 3,
7150
7192
  })}
7151
- @click=${() => this._activateSkillMenuItem(2)}
7193
+ @click=${() => this._activateSkillMenuItem(3)}
7152
7194
  >
7153
7195
  Test Context Warning
7154
7196
  </button>
7155
7197
  <button
7156
7198
  class=${e({
7157
7199
  'skill-menu__action': true,
7158
- 'skill-menu__item--highlighted': this._skillHighlighted === 3,
7200
+ 'skill-menu__item--highlighted': this._skillHighlighted === 4,
7159
7201
  })}
7160
- @click=${() => this._activateSkillMenuItem(3)}
7202
+ @click=${() => this._activateSkillMenuItem(4)}
7161
7203
  >
7162
7204
  Test Context Limit
7163
7205
  </button>
@@ -7603,19 +7645,41 @@
7603
7645
  ` : A}
7604
7646
  ${msg.conversationFiles && msg.conversationFiles.length ? b `
7605
7647
  <div class="attachments">
7606
- ${msg.conversationFiles.map((img) => b `
7607
- <button
7608
- class="attachment"
7609
- type="button"
7610
- title=${img.niceName || 'View image'}
7611
- @click=${() => this._openImage(img)}
7612
- >
7613
- <img
7614
- src=${img.previewUrl || `${krubbleHttp.Http.getBaseUrl()}/api/system/conversationFile/${img.conversationFileID}/view`}
7615
- alt=${img.niceName || ''}
7616
- />
7617
- </button>
7618
- `)}
7648
+ ${msg.conversationFiles.map((file) => {
7649
+ const fileName = file.niceName || file.name || 'File';
7650
+ // A stored file is an image by its MIME when it has one, else
7651
+ // by its extension.
7652
+ let isImage = IMAGE_EXTENSIONS.includes(this._getFileExtension(fileName));
7653
+ if (file.mimeType) {
7654
+ isImage = file.mimeType.startsWith('image/');
7655
+ }
7656
+ // Images open in the previewer; documents just show a tile
7657
+ // (the previewer is image-only).
7658
+ if (isImage) {
7659
+ return b `
7660
+ <button
7661
+ class="attachment"
7662
+ type="button"
7663
+ title=${file.niceName || 'View image'}
7664
+ @click=${() => this._handleImageOpen(file)}
7665
+ >
7666
+ <img
7667
+ src=${file.previewUrl || `${krubbleHttp.Http.getBaseUrl()}/api/system/conversationFile/${file.conversationFileID}/view`}
7668
+ alt=${file.niceName || ''}
7669
+ />
7670
+ </button>
7671
+ `;
7672
+ }
7673
+ return b `
7674
+ <div
7675
+ class="attachment attachment--doc"
7676
+ title=${fileName}
7677
+ >
7678
+ <span class="attachment__type">${this._getFileExtension(fileName) || 'File'}</span>
7679
+ <span class="attachment__name">${fileName}</span>
7680
+ </div>
7681
+ `;
7682
+ })}
7619
7683
  </div>
7620
7684
  ` : A}
7621
7685
  ${msg.content ? b `
@@ -7730,18 +7794,36 @@
7730
7794
  ${this._pendingConversationFiles.length ? b `
7731
7795
  <div class="composer-attachments">
7732
7796
  ${c(this._pendingConversationFiles, (p) => p.localId, (p) => b `
7733
- <div class="chip">
7734
- <img src=${p.previewUrl} alt=${p.name} />
7797
+ <div
7798
+ class=${e({
7799
+ chip: true,
7800
+ 'chip--doc': !p.previewUrl,
7801
+ })}
7802
+ title=${p.name}
7803
+ >
7804
+ <div
7805
+ class=${e({
7806
+ 'chip-body': true,
7807
+ 'chip-body--image': Boolean(p.previewUrl),
7808
+ })}
7809
+ >
7810
+ ${p.previewUrl
7811
+ ? b `<img src=${p.previewUrl} alt=${p.name} />`
7812
+ : b `
7813
+ <span class="chip-type">${this._getFileExtension(p.name) || 'File'}</span>
7814
+ <span class="chip-name">${p.name}</span>
7815
+ `}
7816
+ </div>
7735
7817
  <button
7736
7818
  class="chip-remove"
7737
7819
  title="Remove"
7738
- @click=${() => this._removePending(p.localId)}
7820
+ @click=${() => this._handlePendingRemove(p.localId)}
7739
7821
  >
7740
7822
  <svg
7741
7823
  xmlns="http://www.w3.org/2000/svg"
7742
7824
  fill="none"
7743
7825
  viewBox="0 0 24 24"
7744
- stroke-width="3"
7826
+ stroke-width="1.75"
7745
7827
  stroke="currentColor"
7746
7828
  >
7747
7829
  <path
@@ -7758,7 +7840,7 @@
7758
7840
  <input
7759
7841
  class="file-input"
7760
7842
  type="file"
7761
- accept="image/png,image/jpeg,image/gif,image/webp"
7843
+ accept=${ACCEPTED_FILE_EXTENSIONS.map((ext) => '.' + ext).join(',')}
7762
7844
  multiple
7763
7845
  hidden
7764
7846
  @change=${this._handleFileInputChange}
@@ -7803,7 +7885,7 @@
7803
7885
  <button
7804
7886
  class=${e({
7805
7887
  'send': true,
7806
- 'send--disabled': !this._canSend(),
7888
+ 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
7807
7889
  })}
7808
7890
  @click=${this._handleSubmit}
7809
7891
  title="Send message"
@@ -8732,13 +8814,15 @@
8732
8814
  margin-bottom: 24px;
8733
8815
  }
8734
8816
 
8817
+ /* A sent attachment is the same tile as the composer chip, so a file looks
8818
+ identical before and after sending. */
8735
8819
  .attachment {
8736
- width: 120px;
8737
- height: 120px;
8738
- border-radius: 12px;
8820
+ width: 112px;
8821
+ height: 112px;
8822
+ border-radius: 20px;
8739
8823
  overflow: hidden;
8740
- background: #f2f2f4;
8741
- border: 1px solid rgba(0, 0, 0, 0.08);
8824
+ background: #f2f0f0;
8825
+ border: none;
8742
8826
  cursor: zoom-in;
8743
8827
  flex-shrink: 0;
8744
8828
  padding: 0;
@@ -8751,6 +8835,37 @@
8751
8835
  display: block;
8752
8836
  }
8753
8837
 
8838
+ /* Non-image attachment in a sent message — file-type label above the filename
8839
+ (the previewer is image-only, so this isn't clickable). */
8840
+ .attachment--doc {
8841
+ display: flex;
8842
+ flex-direction: column;
8843
+ padding: 16px 14px;
8844
+ box-sizing: border-box;
8845
+ text-align: left;
8846
+ cursor: default;
8847
+ }
8848
+
8849
+ .attachment__type {
8850
+ font-size: 13px;
8851
+ line-height: 1.2;
8852
+ color: #444746;
8853
+ text-transform: uppercase;
8854
+ flex-shrink: 0;
8855
+ }
8856
+
8857
+ .attachment__name {
8858
+ margin-top: auto;
8859
+ font-size: 13px;
8860
+ line-height: 1.25;
8861
+ color: #000;
8862
+ overflow: hidden;
8863
+ display: -webkit-box;
8864
+ -webkit-line-clamp: 3;
8865
+ -webkit-box-orient: vertical;
8866
+ overflow-wrap: anywhere;
8867
+ }
8868
+
8754
8869
  /* Skill menu (opened by typing '/'). Fixed + JS-positioned above the
8755
8870
  composer, following the krubble select-field dropdown pattern. */
8756
8871
  .skill-menu {
@@ -8825,6 +8940,9 @@
8825
8940
  }
8826
8941
 
8827
8942
  .skill-menu__action {
8943
+ display: flex;
8944
+ flex-direction: column;
8945
+ gap: 2px;
8828
8946
  width: 100%;
8829
8947
  padding: 6px 16px;
8830
8948
  background: transparent;
@@ -8837,6 +8955,13 @@
8837
8955
  font-weight: 500;
8838
8956
  }
8839
8957
 
8958
+ /* Secondary line on an attach action (e.g. "reads these files") — muted,
8959
+ not the primary accent, so it reads as a hint under the action label. */
8960
+ .skill-menu__action .skill-menu__desc {
8961
+ font-weight: 400;
8962
+ color: #6b7280;
8963
+ }
8964
+
8840
8965
  /* Highlight (keyboard) and hover (mouse) are independent — either shades the
8841
8966
  row. Declared after the base rules so it wins the cascade for both the
8842
8967
  action buttons and the skill items. */
@@ -8953,15 +9078,44 @@
8953
9078
  padding: 0 6px 10px;
8954
9079
  }
8955
9080
 
9081
+ /* Every attachment — image or document — is the same square tile, so a mixed
9082
+ row reads as one uniform set. */
8956
9083
  .chip {
8957
9084
  position: relative;
8958
- width: 56px;
8959
- height: 56px;
8960
- border-radius: 10px;
9085
+ display: flex;
9086
+ flex-direction: column;
9087
+ width: 112px;
9088
+ height: 112px;
9089
+ border-radius: 20px;
8961
9090
  overflow: hidden;
8962
- background: #f2f2f4;
8963
- border: 1px solid var(--kr-chatbot-border);
9091
+ background: #f2f0f0;
8964
9092
  flex-shrink: 0;
9093
+ transition: background 0.28s ease;
9094
+ }
9095
+
9096
+ .chip--doc {
9097
+ cursor: pointer;
9098
+ }
9099
+
9100
+ .chip--doc:hover {
9101
+ background: #dfdddd;
9102
+ }
9103
+
9104
+ /* The preview body — an image fills the tile; a document shows its file-type
9105
+ label above the filename, both left-aligned. */
9106
+ .chip-body {
9107
+ flex: 1;
9108
+ display: flex;
9109
+ flex-direction: column;
9110
+ min-height: 0;
9111
+ padding: 16px 14px;
9112
+ box-sizing: border-box;
9113
+ overflow: hidden;
9114
+ }
9115
+
9116
+ /* An image fills the whole tile, so it needs no padding around it. */
9117
+ .chip-body--image {
9118
+ padding: 0;
8965
9119
  }
8966
9120
 
8967
9121
  .chip img {
@@ -8971,31 +9125,65 @@
8971
9125
  display: block;
8972
9126
  }
8973
9127
 
9128
+ /* File-type label (PDF, XLSX, …) — sits at the top of the tile. */
9129
+ .chip-type {
9130
+ font-size: 13px;
9131
+ line-height: 1.2;
9132
+ color: #444746;
9133
+ text-transform: uppercase;
9134
+ flex-shrink: 0;
9135
+ }
9136
+
9137
+ /* Filename — pinned to the bottom of the tile, wrapping to at most three
9138
+ lines before it ellipsizes. */
9139
+ .chip-name {
9140
+ margin-top: auto;
9141
+ font-size: 13px;
9142
+ line-height: 1.25;
9143
+ color: #000;
9144
+ overflow: hidden;
9145
+ display: -webkit-box;
9146
+ -webkit-line-clamp: 3;
9147
+ -webkit-box-orient: vertical;
9148
+ overflow-wrap: anywhere;
9149
+ }
9150
+
9151
+ /* Remove button — a bare X in the tile's top-right corner, revealed only when
9152
+ the tile (or the button itself) is hovered or keyboard-focused. */
8974
9153
  .chip-remove {
8975
9154
  position: absolute;
8976
- top: 2px;
8977
- right: 2px;
8978
- width: 18px;
8979
- height: 18px;
8980
- border-radius: 50%;
9155
+ /* Inset matches .chip-body's padding so the X sits the same distance from
9156
+ the right edge as the file-type label does from the left. */
9157
+ top: 14px;
9158
+ right: 14px;
9159
+ width: 15px;
9160
+ height: 15px;
8981
9161
  border: none;
8982
- background: rgba(0, 0, 0, 0.6);
8983
- color: #fff;
9162
+ background: none;
9163
+ color: #444746;
8984
9164
  cursor: pointer;
8985
9165
  display: flex;
8986
9166
  align-items: center;
8987
9167
  justify-content: center;
8988
9168
  padding: 0;
8989
9169
  line-height: 1;
9170
+ flex-shrink: 0;
9171
+ opacity: 0;
9172
+ transition: opacity 0.28s ease;
9173
+ }
9174
+
9175
+ .chip:hover .chip-remove,
9176
+ .chip-remove:focus-visible {
9177
+ opacity: 1;
8990
9178
  }
8991
9179
 
8992
9180
  .chip-remove:hover {
8993
- background: rgba(0, 0, 0, 0.8);
9181
+ color: #000;
8994
9182
  }
8995
9183
 
8996
9184
  .chip-remove svg {
8997
- width: 10px;
8998
- height: 10px;
9185
+ width: 15px;
9186
+ height: 15px;
8999
9187
  }
9000
9188
 
9001
9189
  /* Drag-and-drop overlay */