@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.
package/dist/chatbot.js CHANGED
@@ -15,8 +15,18 @@ import { marked } from 'marked';
15
15
  // (kr-dialog, kr-tab-group, kr-tab, kr-text-field) used by the browse dialog.
16
16
  import { KRFilePreview } from '@kodaris/krubble-components';
17
17
  import { KRHttp } from '@kodaris/krubble-http';
18
- /** Image MIME types the chatbot accepts (matches what Bedrock can ingest). */
19
- const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
18
+ /** Image extensions the chatbot renders as thumbnails (used when MIME is absent). */
19
+ const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
20
+ /**
21
+ * File extensions the picker accepts. Mirrors what the backend can read: the
22
+ * document/spreadsheet family Bedrock ingests, the plain-text family read as
23
+ * fallback, and the image formats. This is a UX convenience — the backend
24
+ * accepts anything and just tells the model when a type can't be read.
25
+ */
26
+ const ACCEPTED_FILE_EXTENSIONS = [
27
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'html', 'md', 'txt', 'json', 'xml',
28
+ ...IMAGE_EXTENSIONS,
29
+ ];
20
30
  /**
21
31
  * AI chatbot component with SSE streaming.
22
32
  *
@@ -81,6 +91,13 @@ let KRChatbot = class KRChatbot extends LitElement {
81
91
  this._dragDepth = 0;
82
92
  /** Monotonic id source for pending attachments. */
83
93
  this._pendingSeq = 0;
94
+ /**
95
+ * The `aiAnalyze` mode for the next picked files — set when the attach action
96
+ * opens the picker (true = "Attach Files", false = "Attach Files for
97
+ * Reference"), read by the file-input change handler. Not reactive: it's read
98
+ * synchronously in the change handler, never rendered.
99
+ */
100
+ this._pendingAttachAnalyze = true;
84
101
  /**
85
102
  * Request-id guards for skill searches — bumped on each search so out-of-order
86
103
  * responses are dropped (mirrors the kr-combo-box `_requestId` pattern). Org and
@@ -197,7 +214,7 @@ let KRChatbot = class KRChatbot extends LitElement {
197
214
  this._debugDrawerWidth = 430;
198
215
  /** True while the user is dragging the debug drawer's resize handle. */
199
216
  this._isDebugResizing = false;
200
- /** Images staged in the composer, before the message is sent. */
217
+ /** Files staged in the composer, before the message is sent. */
201
218
  this._pendingConversationFiles = [];
202
219
  /** True while a file is being dragged over the panel (shows the drop overlay). */
203
220
  this._isDraggingFile = false;
@@ -296,7 +313,9 @@ let KRChatbot = class KRChatbot extends LitElement {
296
313
  this._isResizing = false;
297
314
  this._panelEl.classList.remove('panel--resizing');
298
315
  }
299
- this._hideOverlay();
316
+ if (this._overlay) {
317
+ this._overlay.style.display = 'none';
318
+ }
300
319
  document.removeEventListener('mousemove', this._handleMouseMove);
301
320
  document.removeEventListener('mouseup', this._handleMouseUp);
302
321
  this._saveState();
@@ -395,7 +414,11 @@ let KRChatbot = class KRChatbot extends LitElement {
395
414
  document.removeEventListener('mousemove', this._handleMouseMove);
396
415
  document.removeEventListener('mouseup', this._handleMouseUp);
397
416
  // Release any object URLs still held by staged attachments.
398
- this._pendingConversationFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
417
+ this._pendingConversationFiles.forEach((p) => {
418
+ if (p.previewUrl) {
419
+ URL.revokeObjectURL(p.previewUrl);
420
+ }
421
+ });
399
422
  }
400
423
  updated(changed) {
401
424
  if (changed.has('messages')) {
@@ -500,11 +523,6 @@ let KRChatbot = class KRChatbot extends LitElement {
500
523
  }
501
524
  this._overlay.style.display = 'block';
502
525
  }
503
- _hideOverlay() {
504
- if (this._overlay) {
505
- this._overlay.style.display = 'none';
506
- }
507
- }
508
526
  _handleHeaderMouseDown(e) {
509
527
  // Inline mode fills its parent — dragging the panel doesn't apply.
510
528
  if (this.variant === 'inline') {
@@ -674,13 +692,14 @@ let KRChatbot = class KRChatbot extends LitElement {
674
692
  }
675
693
  _handleKeyDown(e) {
676
694
  // When the skill menu is open, arrow keys / Enter / Escape drive it. The top
677
- // actions (Attach Files, Browse All Skills, plus the two debug test triggers
678
- // when enabled) come first, then the filtered skills — all one navigable list.
695
+ // actions (Attach Files, Attach Files for Reference, Browse All Skills, plus
696
+ // the two debug test triggers when enabled) come first, then the filtered
697
+ // skills — all one navigable list.
679
698
  if (this._skillMenuOpened) {
680
699
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
681
- let actionCount = 2;
700
+ let actionCount = 3;
682
701
  if (debugActions) {
683
- actionCount = 4;
702
+ actionCount = 5;
684
703
  }
685
704
  const count = actionCount + this._invocableSkills.length;
686
705
  if (e.key === 'ArrowDown') {
@@ -712,19 +731,23 @@ let KRChatbot = class KRChatbot extends LitElement {
712
731
  /** Run the skill-menu item at the given flat index (actions first, then skills). */
713
732
  _activateSkillMenuItem(index) {
714
733
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
715
- let actionCount = 2;
734
+ let actionCount = 3;
716
735
  if (debugActions) {
717
- actionCount = 4;
736
+ actionCount = 5;
718
737
  }
719
738
  if (index === 0) {
720
- this._openFilePicker();
739
+ this._openFilePicker(true);
721
740
  return;
722
741
  }
723
742
  if (index === 1) {
743
+ this._openFilePicker(false);
744
+ return;
745
+ }
746
+ if (index === 2) {
724
747
  this._handleSkillBrowseOpen();
725
748
  return;
726
749
  }
727
- if (debugActions && index === 2) {
750
+ if (debugActions && index === 3) {
728
751
  this._skillMenuOpened = false;
729
752
  this._send({
730
753
  message: '/test-context-warning',
@@ -732,7 +755,7 @@ let KRChatbot = class KRChatbot extends LitElement {
732
755
  });
733
756
  return;
734
757
  }
735
- if (debugActions && index === 3) {
758
+ if (debugActions && index === 4) {
736
759
  this._skillMenuOpened = false;
737
760
  this._send({
738
761
  message: '/test-context-limit',
@@ -1052,17 +1075,26 @@ let KRChatbot = class KRChatbot extends LitElement {
1052
1075
  this._skillBrowseSource = e.detail.activeTabId;
1053
1076
  }
1054
1077
  // --- Attachments ---
1055
- _openFilePicker() {
1078
+ /**
1079
+ * Open the OS file picker. `aiAnalyze` records which attach action was used so
1080
+ * the change handler stages the picked files in the right mode: true for
1081
+ * "Attach Files" (the model reads them), false for "Attach Files for Reference"
1082
+ * (attached for a tool to act on — the model is told about them but not asked
1083
+ * to read them). Chosen once at the picker, so selecting 30 files never means
1084
+ * toggling 30 chips.
1085
+ */
1086
+ _openFilePicker(aiAnalyze = true) {
1056
1087
  // Close the skill/attachment menu — opening the OS file picker is the end of
1057
1088
  // the menu interaction, and the picker is a separate window so the menu would
1058
1089
  // otherwise linger open behind it (and after a file is chosen).
1059
1090
  this._skillMenuOpened = false;
1091
+ this._pendingAttachAnalyze = aiAnalyze;
1060
1092
  this._fileInputEl?.click();
1061
1093
  }
1062
1094
  _handleFileInputChange(e) {
1063
1095
  const input = e.target;
1064
1096
  if (input.files) {
1065
- this._addFiles(input.files);
1097
+ this._addFiles(input.files, this._pendingAttachAnalyze);
1066
1098
  }
1067
1099
  // Reset so picking the same file again re-fires change.
1068
1100
  input.value = '';
@@ -1125,35 +1157,62 @@ let KRChatbot = class KRChatbot extends LitElement {
1125
1157
  _hasFiles(e) {
1126
1158
  return Array.from(e.dataTransfer?.types || []).includes('Files');
1127
1159
  }
1128
- /** Stage a set of image files in the composer (held locally until send). */
1129
- _addFiles(fileList) {
1130
- const files = Array.from(fileList).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type));
1160
+ /** Lower-cased extension of a filename (without the dot), or '' if none. */
1161
+ _getFileExtension(name) {
1162
+ const dot = name.lastIndexOf('.');
1163
+ if (dot < 0) {
1164
+ return '';
1165
+ }
1166
+ return name.slice(dot + 1).toLowerCase();
1167
+ }
1168
+ /**
1169
+ * Stage a set of files in the composer (held locally until send). `aiAnalyze`
1170
+ * comes from which attach action was used — true for "Attach Files" (the model
1171
+ * reads them), false for "Attach Files for Reference" (tool-only, not read).
1172
+ * Drag-and-drop and paste default to analyze.
1173
+ */
1174
+ _addFiles(fileList, aiAnalyze = true) {
1175
+ // Only accept what the picker's `accept` filter allows (extension-based, so a
1176
+ // drag/paste can't sneak in a type the picker itself wouldn't offer).
1177
+ const files = Array.from(fileList).filter((f) => ACCEPTED_FILE_EXTENSIONS.includes(this._getFileExtension(f.name)));
1131
1178
  if (!files.length) {
1132
1179
  return;
1133
1180
  }
1134
1181
  files.forEach((file) => {
1182
+ // Only images get a local preview thumbnail; documents render an icon.
1183
+ // Prefer the File's MIME, falling back to the extension when it's absent.
1184
+ let previewUrl;
1185
+ if (file.type.startsWith('image/') || IMAGE_EXTENSIONS.includes(this._getFileExtension(file.name))) {
1186
+ previewUrl = URL.createObjectURL(file);
1187
+ }
1135
1188
  const pending = {
1136
1189
  localId: 'att-' + this._pendingSeq++,
1137
1190
  file,
1138
1191
  name: file.name,
1139
- previewUrl: URL.createObjectURL(file),
1192
+ mimeType: file.type,
1193
+ previewUrl,
1194
+ aiAnalyze,
1140
1195
  };
1141
1196
  this._pendingConversationFiles = [...this._pendingConversationFiles, pending];
1142
1197
  });
1143
1198
  }
1144
- _removePending(localId) {
1199
+ _handlePendingRemove(localId) {
1145
1200
  const target = this._pendingConversationFiles.find((p) => p.localId === localId);
1146
- if (target) {
1201
+ if (target?.previewUrl) {
1147
1202
  URL.revokeObjectURL(target.previewUrl);
1148
1203
  }
1149
1204
  this._pendingConversationFiles = this._pendingConversationFiles.filter((p) => p.localId !== localId);
1150
1205
  }
1151
1206
  _clearPending() {
1152
- this._pendingConversationFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
1207
+ this._pendingConversationFiles.forEach((p) => {
1208
+ if (p.previewUrl) {
1209
+ URL.revokeObjectURL(p.previewUrl);
1210
+ }
1211
+ });
1153
1212
  this._pendingConversationFiles = [];
1154
1213
  }
1155
1214
  /** Open the shared full-screen previewer for a message image. */
1156
- _openImage(img) {
1215
+ _handleImageOpen(img) {
1157
1216
  // Use the local preview if the upload hasn't finished yet; otherwise the
1158
1217
  // backend view URL (prefixed with the base URL — a raw fetch of a relative
1159
1218
  // path would hit the page origin, e.g. the dev server, not the backend).
@@ -1170,10 +1229,6 @@ let KRChatbot = class KRChatbot extends LitElement {
1170
1229
  window.open(`${KRHttp.getBaseUrl()}/api/system/conversationFile/${img.conversationFileID}/view?download=true`, '_blank');
1171
1230
  });
1172
1231
  }
1173
- /** Whether the send button is active: needs text or at least one image. */
1174
- _canSend() {
1175
- return Boolean(this._inputValue.trim()) || this._pendingConversationFiles.length > 0;
1176
- }
1177
1232
  _handleSubmit() {
1178
1233
  if (this._isStreaming) {
1179
1234
  return;
@@ -1184,10 +1239,13 @@ let KRChatbot = class KRChatbot extends LitElement {
1184
1239
  if (!messageText && !pending.length) {
1185
1240
  return;
1186
1241
  }
1187
- // The raw files sent to the backend. Sent images are shown once the
1188
- // conversation reloads and they come back as stored conversation files; the
1189
- // just-sent message itself carries text only.
1190
- const conversationFiles = pending.map((p) => p.file);
1242
+ // The files sent to the backend, each paired with its analyze flag. Sent
1243
+ // files are shown once the conversation reloads and they come back as stored
1244
+ // conversation files; the just-sent message itself carries text only.
1245
+ const conversationFiles = pending.map((p) => ({
1246
+ file: p.file,
1247
+ aiAnalyze: p.aiAnalyze,
1248
+ }));
1191
1249
  const submitEvent = new CustomEvent('message-submit', {
1192
1250
  detail: {
1193
1251
  message: messageText,
@@ -1219,12 +1277,15 @@ let KRChatbot = class KRChatbot extends LitElement {
1219
1277
  };
1220
1278
  // Attach the staged files' local previews to the message so its thumbnails
1221
1279
  // show INSTANTLY, without waiting on the upload. Each carries a temporary
1222
- // previewUrl (and conversationFileID -1 as a placeholder); the built-in send
1223
- // swaps these for the real server files once the upload returns.
1280
+ // previewUrl (images only; documents render an icon from mimeType) and
1281
+ // conversationFileID -1 as a placeholder; the built-in send swaps these for
1282
+ // the real server files once the upload returns.
1224
1283
  if (this._pendingConversationFiles.length) {
1225
1284
  userMessage.conversationFiles = this._pendingConversationFiles.map((p) => ({
1226
1285
  conversationFileID: -1,
1227
1286
  niceName: p.name,
1287
+ mimeType: p.mimeType,
1288
+ aiAnalyze: p.aiAnalyze,
1228
1289
  previewUrl: p.previewUrl,
1229
1290
  }));
1230
1291
  }
@@ -1279,11 +1340,17 @@ let KRChatbot = class KRChatbot extends LitElement {
1279
1340
  // upload is dropped so an undefined id never reaches `fileIds`.
1280
1341
  let uploaded = Promise.resolve([]);
1281
1342
  if (params.conversationFiles.length) {
1282
- uploaded = Promise.all(params.conversationFiles.map((file) => {
1343
+ uploaded = Promise.all(params.conversationFiles.map(({ file, aiAnalyze }) => {
1283
1344
  const form = new FormData();
1284
1345
  form.append('file', file);
1285
1346
  form.append('type', 'LIBRARY');
1286
1347
  form.append('niceName', file.name);
1348
+ // Reference-only files (from "Attach Files for Reference") aren't read
1349
+ // by the model — the endpoint defaults aiAnalyze=true, so only send
1350
+ // the flag when it's false.
1351
+ if (!aiAnalyze) {
1352
+ form.append('aiAnalyze', 'false');
1353
+ }
1287
1354
  return KRHttp.fetch({
1288
1355
  url: '/api/system/conversationFile/upload',
1289
1356
  method: 'POST',
@@ -1565,9 +1632,9 @@ let KRChatbot = class KRChatbot extends LitElement {
1565
1632
  // flat keyboard index counts actions first, then skills, so the skills'
1566
1633
  // index offset is the number of actions shown.
1567
1634
  const debugActions = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
1568
- let actionCount = 2;
1635
+ let actionCount = 3;
1569
1636
  if (debugActions) {
1570
- actionCount = 4;
1637
+ actionCount = 5;
1571
1638
  }
1572
1639
  return html `
1573
1640
  <div class="skill-menu">
@@ -1577,14 +1644,25 @@ let KRChatbot = class KRChatbot extends LitElement {
1577
1644
  'skill-menu__action': true,
1578
1645
  'skill-menu__item--highlighted': this._skillHighlighted === 0,
1579
1646
  })}
1580
- @click=${this._openFilePicker}
1647
+ @click=${() => this._openFilePicker(true)}
1581
1648
  >
1582
- Attach Files
1649
+ <span class="skill-menu__name">Attach Files</span>
1650
+ <span class="skill-menu__desc">The AI reads these files</span>
1583
1651
  </button>
1584
1652
  <button
1585
1653
  class=${classMap({
1586
1654
  'skill-menu__action': true,
1587
1655
  'skill-menu__item--highlighted': this._skillHighlighted === 1,
1656
+ })}
1657
+ @click=${() => this._openFilePicker(false)}
1658
+ >
1659
+ <span class="skill-menu__name">Attach Files for Reference</span>
1660
+ <span class="skill-menu__desc">Attached for a tool — not read by the AI</span>
1661
+ </button>
1662
+ <button
1663
+ class=${classMap({
1664
+ 'skill-menu__action': true,
1665
+ 'skill-menu__item--highlighted': this._skillHighlighted === 2,
1588
1666
  })}
1589
1667
  @click=${this._handleSkillBrowseOpen}
1590
1668
  >
@@ -1594,18 +1672,18 @@ let KRChatbot = class KRChatbot extends LitElement {
1594
1672
  <button
1595
1673
  class=${classMap({
1596
1674
  'skill-menu__action': true,
1597
- 'skill-menu__item--highlighted': this._skillHighlighted === 2,
1675
+ 'skill-menu__item--highlighted': this._skillHighlighted === 3,
1598
1676
  })}
1599
- @click=${() => this._activateSkillMenuItem(2)}
1677
+ @click=${() => this._activateSkillMenuItem(3)}
1600
1678
  >
1601
1679
  Test Context Warning
1602
1680
  </button>
1603
1681
  <button
1604
1682
  class=${classMap({
1605
1683
  'skill-menu__action': true,
1606
- 'skill-menu__item--highlighted': this._skillHighlighted === 3,
1684
+ 'skill-menu__item--highlighted': this._skillHighlighted === 4,
1607
1685
  })}
1608
- @click=${() => this._activateSkillMenuItem(3)}
1686
+ @click=${() => this._activateSkillMenuItem(4)}
1609
1687
  >
1610
1688
  Test Context Limit
1611
1689
  </button>
@@ -2051,19 +2129,41 @@ let KRChatbot = class KRChatbot extends LitElement {
2051
2129
  ` : nothing}
2052
2130
  ${msg.conversationFiles && msg.conversationFiles.length ? html `
2053
2131
  <div class="attachments">
2054
- ${msg.conversationFiles.map((img) => html `
2055
- <button
2056
- class="attachment"
2057
- type="button"
2058
- title=${img.niceName || 'View image'}
2059
- @click=${() => this._openImage(img)}
2060
- >
2061
- <img
2062
- src=${img.previewUrl || `${KRHttp.getBaseUrl()}/api/system/conversationFile/${img.conversationFileID}/view`}
2063
- alt=${img.niceName || ''}
2064
- />
2065
- </button>
2066
- `)}
2132
+ ${msg.conversationFiles.map((file) => {
2133
+ const fileName = file.niceName || file.name || 'File';
2134
+ // A stored file is an image by its MIME when it has one, else
2135
+ // by its extension.
2136
+ let isImage = IMAGE_EXTENSIONS.includes(this._getFileExtension(fileName));
2137
+ if (file.mimeType) {
2138
+ isImage = file.mimeType.startsWith('image/');
2139
+ }
2140
+ // Images open in the previewer; documents just show a tile
2141
+ // (the previewer is image-only).
2142
+ if (isImage) {
2143
+ return html `
2144
+ <button
2145
+ class="attachment"
2146
+ type="button"
2147
+ title=${file.niceName || 'View image'}
2148
+ @click=${() => this._handleImageOpen(file)}
2149
+ >
2150
+ <img
2151
+ src=${file.previewUrl || `${KRHttp.getBaseUrl()}/api/system/conversationFile/${file.conversationFileID}/view`}
2152
+ alt=${file.niceName || ''}
2153
+ />
2154
+ </button>
2155
+ `;
2156
+ }
2157
+ return html `
2158
+ <div
2159
+ class="attachment attachment--doc"
2160
+ title=${fileName}
2161
+ >
2162
+ <span class="attachment__type">${this._getFileExtension(fileName) || 'File'}</span>
2163
+ <span class="attachment__name">${fileName}</span>
2164
+ </div>
2165
+ `;
2166
+ })}
2067
2167
  </div>
2068
2168
  ` : nothing}
2069
2169
  ${msg.content ? html `
@@ -2178,18 +2278,36 @@ let KRChatbot = class KRChatbot extends LitElement {
2178
2278
  ${this._pendingConversationFiles.length ? html `
2179
2279
  <div class="composer-attachments">
2180
2280
  ${repeat(this._pendingConversationFiles, (p) => p.localId, (p) => html `
2181
- <div class="chip">
2182
- <img src=${p.previewUrl} alt=${p.name} />
2281
+ <div
2282
+ class=${classMap({
2283
+ chip: true,
2284
+ 'chip--doc': !p.previewUrl,
2285
+ })}
2286
+ title=${p.name}
2287
+ >
2288
+ <div
2289
+ class=${classMap({
2290
+ 'chip-body': true,
2291
+ 'chip-body--image': Boolean(p.previewUrl),
2292
+ })}
2293
+ >
2294
+ ${p.previewUrl
2295
+ ? html `<img src=${p.previewUrl} alt=${p.name} />`
2296
+ : html `
2297
+ <span class="chip-type">${this._getFileExtension(p.name) || 'File'}</span>
2298
+ <span class="chip-name">${p.name}</span>
2299
+ `}
2300
+ </div>
2183
2301
  <button
2184
2302
  class="chip-remove"
2185
2303
  title="Remove"
2186
- @click=${() => this._removePending(p.localId)}
2304
+ @click=${() => this._handlePendingRemove(p.localId)}
2187
2305
  >
2188
2306
  <svg
2189
2307
  xmlns="http://www.w3.org/2000/svg"
2190
2308
  fill="none"
2191
2309
  viewBox="0 0 24 24"
2192
- stroke-width="3"
2310
+ stroke-width="1.75"
2193
2311
  stroke="currentColor"
2194
2312
  >
2195
2313
  <path
@@ -2206,7 +2324,7 @@ let KRChatbot = class KRChatbot extends LitElement {
2206
2324
  <input
2207
2325
  class="file-input"
2208
2326
  type="file"
2209
- accept="image/png,image/jpeg,image/gif,image/webp"
2327
+ accept=${ACCEPTED_FILE_EXTENSIONS.map((ext) => '.' + ext).join(',')}
2210
2328
  multiple
2211
2329
  hidden
2212
2330
  @change=${this._handleFileInputChange}
@@ -2251,7 +2369,7 @@ let KRChatbot = class KRChatbot extends LitElement {
2251
2369
  <button
2252
2370
  class=${classMap({
2253
2371
  'send': true,
2254
- 'send--disabled': !this._canSend(),
2372
+ 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
2255
2373
  })}
2256
2374
  @click=${this._handleSubmit}
2257
2375
  title="Send message"
@@ -3180,13 +3298,15 @@ KRChatbot.styles = css `
3180
3298
  margin-bottom: 24px;
3181
3299
  }
3182
3300
 
3301
+ /* A sent attachment is the same tile as the composer chip, so a file looks
3302
+ identical before and after sending. */
3183
3303
  .attachment {
3184
- width: 120px;
3185
- height: 120px;
3186
- border-radius: 12px;
3304
+ width: 112px;
3305
+ height: 112px;
3306
+ border-radius: 20px;
3187
3307
  overflow: hidden;
3188
- background: #f2f2f4;
3189
- border: 1px solid rgba(0, 0, 0, 0.08);
3308
+ background: #f2f0f0;
3309
+ border: none;
3190
3310
  cursor: zoom-in;
3191
3311
  flex-shrink: 0;
3192
3312
  padding: 0;
@@ -3199,6 +3319,37 @@ KRChatbot.styles = css `
3199
3319
  display: block;
3200
3320
  }
3201
3321
 
3322
+ /* Non-image attachment in a sent message — file-type label above the filename
3323
+ (the previewer is image-only, so this isn't clickable). */
3324
+ .attachment--doc {
3325
+ display: flex;
3326
+ flex-direction: column;
3327
+ padding: 16px 14px;
3328
+ box-sizing: border-box;
3329
+ text-align: left;
3330
+ cursor: default;
3331
+ }
3332
+
3333
+ .attachment__type {
3334
+ font-size: 13px;
3335
+ line-height: 1.2;
3336
+ color: #444746;
3337
+ text-transform: uppercase;
3338
+ flex-shrink: 0;
3339
+ }
3340
+
3341
+ .attachment__name {
3342
+ margin-top: auto;
3343
+ font-size: 13px;
3344
+ line-height: 1.25;
3345
+ color: #000;
3346
+ overflow: hidden;
3347
+ display: -webkit-box;
3348
+ -webkit-line-clamp: 3;
3349
+ -webkit-box-orient: vertical;
3350
+ overflow-wrap: anywhere;
3351
+ }
3352
+
3202
3353
  /* Skill menu (opened by typing '/'). Fixed + JS-positioned above the
3203
3354
  composer, following the krubble select-field dropdown pattern. */
3204
3355
  .skill-menu {
@@ -3273,6 +3424,9 @@ KRChatbot.styles = css `
3273
3424
  }
3274
3425
 
3275
3426
  .skill-menu__action {
3427
+ display: flex;
3428
+ flex-direction: column;
3429
+ gap: 2px;
3276
3430
  width: 100%;
3277
3431
  padding: 6px 16px;
3278
3432
  background: transparent;
@@ -3285,6 +3439,13 @@ KRChatbot.styles = css `
3285
3439
  font-weight: 500;
3286
3440
  }
3287
3441
 
3442
+ /* Secondary line on an attach action (e.g. "reads these files") — muted,
3443
+ not the primary accent, so it reads as a hint under the action label. */
3444
+ .skill-menu__action .skill-menu__desc {
3445
+ font-weight: 400;
3446
+ color: #6b7280;
3447
+ }
3448
+
3288
3449
  /* Highlight (keyboard) and hover (mouse) are independent — either shades the
3289
3450
  row. Declared after the base rules so it wins the cascade for both the
3290
3451
  action buttons and the skill items. */
@@ -3401,15 +3562,44 @@ KRChatbot.styles = css `
3401
3562
  padding: 0 6px 10px;
3402
3563
  }
3403
3564
 
3565
+ /* Every attachment — image or document — is the same square tile, so a mixed
3566
+ row reads as one uniform set. */
3404
3567
  .chip {
3405
3568
  position: relative;
3406
- width: 56px;
3407
- height: 56px;
3408
- border-radius: 10px;
3569
+ display: flex;
3570
+ flex-direction: column;
3571
+ width: 112px;
3572
+ height: 112px;
3573
+ border-radius: 20px;
3409
3574
  overflow: hidden;
3410
- background: #f2f2f4;
3411
- border: 1px solid var(--kr-chatbot-border);
3575
+ background: #f2f0f0;
3412
3576
  flex-shrink: 0;
3577
+ transition: background 0.28s ease;
3578
+ }
3579
+
3580
+ .chip--doc {
3581
+ cursor: pointer;
3582
+ }
3583
+
3584
+ .chip--doc:hover {
3585
+ background: #dfdddd;
3586
+ }
3587
+
3588
+ /* The preview body — an image fills the tile; a document shows its file-type
3589
+ label above the filename, both left-aligned. */
3590
+ .chip-body {
3591
+ flex: 1;
3592
+ display: flex;
3593
+ flex-direction: column;
3594
+ min-height: 0;
3595
+ padding: 16px 14px;
3596
+ box-sizing: border-box;
3597
+ overflow: hidden;
3598
+ }
3599
+
3600
+ /* An image fills the whole tile, so it needs no padding around it. */
3601
+ .chip-body--image {
3602
+ padding: 0;
3413
3603
  }
3414
3604
 
3415
3605
  .chip img {
@@ -3419,31 +3609,65 @@ KRChatbot.styles = css `
3419
3609
  display: block;
3420
3610
  }
3421
3611
 
3612
+ /* File-type label (PDF, XLSX, …) — sits at the top of the tile. */
3613
+ .chip-type {
3614
+ font-size: 13px;
3615
+ line-height: 1.2;
3616
+ color: #444746;
3617
+ text-transform: uppercase;
3618
+ flex-shrink: 0;
3619
+ }
3620
+
3621
+ /* Filename — pinned to the bottom of the tile, wrapping to at most three
3622
+ lines before it ellipsizes. */
3623
+ .chip-name {
3624
+ margin-top: auto;
3625
+ font-size: 13px;
3626
+ line-height: 1.25;
3627
+ color: #000;
3628
+ overflow: hidden;
3629
+ display: -webkit-box;
3630
+ -webkit-line-clamp: 3;
3631
+ -webkit-box-orient: vertical;
3632
+ overflow-wrap: anywhere;
3633
+ }
3634
+
3635
+ /* Remove button — a bare X in the tile's top-right corner, revealed only when
3636
+ the tile (or the button itself) is hovered or keyboard-focused. */
3422
3637
  .chip-remove {
3423
3638
  position: absolute;
3424
- top: 2px;
3425
- right: 2px;
3426
- width: 18px;
3427
- height: 18px;
3428
- border-radius: 50%;
3639
+ /* Inset matches .chip-body's padding so the X sits the same distance from
3640
+ the right edge as the file-type label does from the left. */
3641
+ top: 14px;
3642
+ right: 14px;
3643
+ width: 15px;
3644
+ height: 15px;
3429
3645
  border: none;
3430
- background: rgba(0, 0, 0, 0.6);
3431
- color: #fff;
3646
+ background: none;
3647
+ color: #444746;
3432
3648
  cursor: pointer;
3433
3649
  display: flex;
3434
3650
  align-items: center;
3435
3651
  justify-content: center;
3436
3652
  padding: 0;
3437
3653
  line-height: 1;
3654
+ flex-shrink: 0;
3655
+ opacity: 0;
3656
+ transition: opacity 0.28s ease;
3657
+ }
3658
+
3659
+ .chip:hover .chip-remove,
3660
+ .chip-remove:focus-visible {
3661
+ opacity: 1;
3438
3662
  }
3439
3663
 
3440
3664
  .chip-remove:hover {
3441
- background: rgba(0, 0, 0, 0.8);
3665
+ color: #000;
3442
3666
  }
3443
3667
 
3444
3668
  .chip-remove svg {
3445
- width: 10px;
3446
- height: 10px;
3669
+ width: 15px;
3670
+ height: 15px;
3447
3671
  }
3448
3672
 
3449
3673
  /* Drag-and-drop overlay */